feat: theme selector (#95)

* feat: Add reusable theme system with Plan 9 proof of concept

Implement a comprehensive theme system that:
- Defines typed Theme interface with colors, syntax highlighting, scrollbar, and gradient variables
- Creates ThemeProvider with React context for runtime theme switching
- Persists theme selection to localStorage
- Includes 3 built-in themes: dark (default), light, and plan9

Theme structure supports:
- Core UI colors (background, foreground, primary, secondary, accent, etc.)
- Status colors (success, warning, info) replacing hardcoded Tailwind colors
- Syntax highlighting variables for code blocks
- Diff highlighting colors (inserted, deleted, meta)
- Scrollbar styling variables
- Gradient colors for branding

Technical changes:
- Update CSS to use new theme variables throughout
- Update prism-theme.css to use syntax variables instead of hardcoded values
- Remove chart colors (unused)
- Add success/warning/info to tailwind.config.js
- Wire up ThemeProvider in main.tsx

For Nostr publishing (future):
- d tag: "grimoire-theme"
- name tag: theme display name

* feat: Add theme selector to user menu, remove configurable border radius

- Remove border radius from theme configuration (borders are always square)
- Add theme selector dropdown to user menu (available to all users)
- Theme selector shows active theme indicator
- Theme selection persists via localStorage

* fix: Improve theme contrast and persistence

- Fix theme persistence: properly check localStorage before using default
- Plan9: make blue subtler (reduce saturation), darken gradient colors
  for better contrast on pale yellow background
- Light theme: improve contrast with darker muted foreground and borders
- Change theme selector from flat list to dropdown submenu

* fix: Replace Plan9 yellow accent with purple, add zap/live theme colors

- Replace Plan9's bright yellow accent with purple (good contrast on pale yellow)
- Add zap and live colors to theme system (used by ZapReceiptRenderer, StatusBadge)
- Make light theme gradient orange darker for better contrast
- Update ZapReceiptRenderer to use theme zap color instead of hardcoded yellow-500
- Update StatusBadge to use theme live color instead of hardcoded red-600
- Add CSS variables and Tailwind utilities for zap/live colors

* fix: Make gradient orange darker, theme status colors

- Make gradient orange darker in light and plan9 themes for better contrast
- Make req viewer status colors themeable:
  - loading/connecting → text-warning
  - live/receiving → text-success
  - error/failed → text-destructive
  - eose → text-info
- Update relay status icons to use theme colors
- Update tests to expect theme color classes

* fix: Use themeable zap color for active user names

- Replace hardcoded text-orange-400 with text-zap in UserName component
- Replace hardcoded text-orange-400 with text-zap in SpellRenderer ($me placeholder)
- Now uses dark amber/gold with proper contrast on light/plan9 themes

* feat: Add highlight theme color for active user display

Add dedicated 'highlight' color to theme system for displaying the
logged-in user's name, replacing the use of 'zap' color which felt
semantically incorrect. The highlight color is optimized for contrast
on each theme's background.

- Add highlight to ThemeColors interface and apply.ts
- Add --highlight CSS variable to index.css (light and dark)
- Add highlight to tailwind.config.js
- Configure appropriate highlight values for dark, light, and plan9 themes
- Update UserName.tsx to use text-highlight for active account
- Update SpellRenderer.tsx MePlaceholder to use text-highlight

* fix: Restore original orange-400 highlight color for dark theme

Update dark theme highlight to match original text-orange-400 color
(27 96% 61%) for backward compatibility with existing appearance.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-14 19:24:37 +01:00
committed by GitHub
parent 5fa2a1c9b8
commit f464c68bde
20 changed files with 1064 additions and 110 deletions

View File

@@ -16,7 +16,7 @@ export function StatusBadge({
const config = {
live: {
label: "LIVE",
className: "bg-red-600 text-white",
className: "bg-live text-white",
icon: Circle,
},
planned: {

View File

@@ -13,7 +13,7 @@ interface UserNameProps {
* Component that displays a user's name from their Nostr profile
* Shows placeholder derived from pubkey while loading or if no profile exists
* Clicking opens the user's profile
* Uses orange-400 color for the logged-in user
* Uses highlight color for the logged-in user (themeable amber)
*/
export function UserName({ pubkey, isMention, className }: UserNameProps) {
const { addWindow, state } = useGrimoire();
@@ -33,7 +33,7 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
dir="auto"
className={cn(
"font-semibold cursor-crosshair hover:underline hover:decoration-dotted",
isActiveAccount ? "text-orange-400" : "text-accent",
isActiveAccount ? "text-highlight" : "text-accent",
className,
)}
onClick={handleClick}

View File

@@ -42,7 +42,7 @@ export function MePlaceholder({
return (
<span
className={cn(
"inline-flex items-center gap-1.5 font-bold text-orange-400 select-none",
"inline-flex items-center gap-1.5 font-bold text-highlight select-none",
pubkey && "cursor-crosshair hover:underline decoration-dotted",
size === "sm" ? "text-xs" : size === "md" ? "text-sm" : "text-lg",
className,

View File

@@ -66,8 +66,8 @@ export function Kind9735Renderer({ event }: BaseEventProps) {
<div className="flex flex-col gap-2">
{/* Zap indicator */}
<div className="flex items-center gap-2">
<Zap className="size-5 fill-yellow-500 text-yellow-500" />
<span className="text-lg font-light text-yellow-500">
<Zap className="size-5 fill-zap text-zap" />
<span className="text-lg font-light text-zap">
{amountInSats.toLocaleString("en", {
notation: "compact",
})}

View File

@@ -1,4 +1,4 @@
import { User, HardDrive } from "lucide-react";
import { User, HardDrive, Palette } from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -13,6 +13,9 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Nip05 from "./nip05";
@@ -20,6 +23,7 @@ import { RelayLink } from "./RelayLink";
import SettingsDialog from "@/components/SettingsDialog";
import LoginDialog from "./LoginDialog";
import { useState } from "react";
import { useTheme } from "@/lib/themes";
function UserAvatar({ pubkey }: { pubkey: string }) {
const profile = useProfile(pubkey);
@@ -57,6 +61,7 @@ export default function UserMenu() {
const blossomServers = state.activeAccount?.blossomServers;
const [showSettings, setShowSettings] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const { themeId, setTheme, availableThemes } = useTheme();
function openProfile() {
if (!account?.pubkey) return;
@@ -153,22 +158,66 @@ export default function UserMenu() {
)}
<DropdownMenuSeparator />
{/* <DropdownMenuItem
onClick={() => setShowSettings(true)}
className="cursor-pointer"
>
<Settings className="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator /> */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-crosshair">
<Palette className="size-4 mr-2" />
Theme
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableThemes.map((theme) => (
<DropdownMenuItem
key={theme.id}
className="cursor-crosshair"
onClick={() => setTheme(theme.id)}
>
<span
className={`size-2 rounded-full mr-2 ${
themeId === theme.id
? "bg-primary"
: "bg-muted-foreground/30"
}`}
/>
{theme.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="cursor-crosshair">
Log out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => setShowLogin(true)}>
Log in
</DropdownMenuItem>
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-crosshair">
<Palette className="size-4 mr-2" />
Theme
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{availableThemes.map((theme) => (
<DropdownMenuItem
key={theme.id}
className="cursor-crosshair"
onClick={() => setTheme(theme.id)}
>
<span
className={`size-2 rounded-full mr-2 ${
themeId === theme.id
? "bg-primary"
: "bg-muted-foreground/30"
}`}
/>
{theme.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowLogin(true)}>
Log in
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -5,10 +5,10 @@
@tailwind components;
@tailwind utilities;
/* Custom scrollbar styling */
/* Custom scrollbar styling - uses theme variables */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
}
*::-webkit-scrollbar {
@@ -17,20 +17,21 @@
}
*::-webkit-scrollbar-track {
background: transparent;
background: hsl(var(--scrollbar-track));
}
*::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
background-color: hsl(var(--scrollbar-thumb));
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
background-color: hsl(var(--scrollbar-thumb-hover));
}
@layer base {
:root {
/* Core colors - light theme defaults (overridden by ThemeProvider) */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
@@ -51,12 +52,50 @@
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* Status colors */
--success: 142 76% 36%;
--warning: 45 93% 47%;
--info: 199 89% 48%;
/* Nostr-specific colors */
--zap: 45 93% 40%;
--live: 0 72% 45%;
/* UI highlight (active user, self-references) */
--highlight: 25 90% 35%;
/* Syntax highlighting */
--syntax-comment: 215.4 16.3% 46.9%;
--syntax-punctuation: 222.2 84% 30%;
--syntax-property: 222.2 47.4% 11.2%;
--syntax-string: 142 60% 30%;
--syntax-keyword: 270 80% 50%;
--syntax-function: 222.2 47.4% 11.2%;
--syntax-variable: 222.2 84% 4.9%;
--syntax-operator: 222.2 84% 20%;
/* Diff colors */
--diff-inserted: 142 60% 30%;
--diff-inserted-bg: 142 60% 50% / 0.15;
--diff-deleted: 0 70% 45%;
--diff-deleted-bg: 0 70% 50% / 0.15;
--diff-meta: 199 80% 40%;
--diff-meta-bg: 199 80% 50% / 0.1;
/* Scrollbar */
--scrollbar-thumb: 222.2 84% 4.9% / 0.2;
--scrollbar-thumb-hover: 222.2 84% 4.9% / 0.3;
--scrollbar-track: 0 0% 0% / 0;
/* Gradient colors (RGB values) */
--gradient-1: 234 179 8;
--gradient-2: 249 115 22;
--gradient-3: 147 51 234;
--gradient-4: 6 182 212;
}
/* Dark theme - applied via .dark class or ThemeProvider */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -77,11 +116,47 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* Status colors */
--success: 142 76% 36%;
--warning: 45 93% 47%;
--info: 199 89% 48%;
/* Nostr-specific colors */
--zap: 45 93% 58%;
--live: 0 72% 51%;
/* UI highlight (active user, self-references) */
--highlight: 27 96% 61%;
/* Syntax highlighting */
--syntax-comment: 215 20.2% 70%;
--syntax-punctuation: 210 40% 70%;
--syntax-property: 210 40% 98%;
--syntax-string: 215 20.2% 70%;
--syntax-keyword: 210 40% 98%;
--syntax-function: 210 40% 98%;
--syntax-variable: 210 40% 98%;
--syntax-operator: 210 40% 98%;
/* Diff colors */
--diff-inserted: 134 60% 76%;
--diff-inserted-bg: 145 63% 42% / 0.1;
--diff-deleted: 0 100% 76%;
--diff-deleted-bg: 0 100% 60% / 0.1;
--diff-meta: 190 77% 70%;
--diff-meta-bg: 190 77% 70% / 0.08;
/* Scrollbar */
--scrollbar-thumb: 0 0% 100% / 0.2;
--scrollbar-thumb-hover: 0 0% 100% / 0.3;
--scrollbar-track: 0 0% 0% / 0;
/* Gradient colors (RGB values) */
--gradient-1: 250 204 21;
--gradient-2: 251 146 60;
--gradient-3: 168 85 247;
--gradient-4: 34 211 238;
}
}
@@ -106,10 +181,10 @@
.text-grimoire-gradient {
background: linear-gradient(
to bottom,
rgb(250 204 21),
/* yellow-400 */ rgb(251 146 60),
/* orange-400 */ rgb(168 85 247),
/* purple-500 */ rgb(34 211 238) /* cyan-400 */
rgb(var(--gradient-1)),
rgb(var(--gradient-2)),
rgb(var(--gradient-3)),
rgb(var(--gradient-4))
);
background-clip: text;
-webkit-background-clip: text;
@@ -117,16 +192,16 @@
}
}
/* react-medium-image-zoom dark theme customization */
/* react-medium-image-zoom theme customization - uses background with opacity */
[data-rmiz-modal-overlay] {
background-color: rgba(12, 12, 18, 0.92) !important;
background-color: hsl(var(--background) / 0.92) !important;
}
[data-rmiz-modal-content] {
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
box-shadow: 0 0 40px hsl(var(--foreground) / 0.2);
}
/* React Mosaic Dark Theme Customization */
/* React Mosaic Theme Customization */
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme {
background: hsl(var(--background));
}

View File

@@ -24,11 +24,11 @@ export function getConnectionIcon(relay: RelayState | undefined) {
const iconMap = {
connected: {
icon: <Wifi className="size-3 text-green-600/70" />,
icon: <Wifi className="size-3 text-success/70" />,
label: "Connected",
},
connecting: {
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
icon: <Loader2 className="size-3 text-warning/70 animate-spin" />,
label: "Connecting",
},
disconnected: {
@@ -36,7 +36,7 @@ export function getConnectionIcon(relay: RelayState | undefined) {
label: "Disconnected",
},
error: {
icon: <XCircle className="size-3 text-red-600/70" />,
icon: <XCircle className="size-3 text-destructive/70" />,
label: "Connection Error",
},
};
@@ -57,19 +57,19 @@ export function getAuthIcon(relay: RelayState | undefined) {
const iconMap = {
authenticated: {
icon: <ShieldCheck className="size-3 text-green-600/70" />,
icon: <ShieldCheck className="size-3 text-success/70" />,
label: "Authenticated",
},
challenge_received: {
icon: <ShieldQuestion className="size-3 text-yellow-600/70" />,
icon: <ShieldQuestion className="size-3 text-warning/70" />,
label: "Challenge Received",
},
authenticating: {
icon: <Loader2 className="size-3 text-yellow-600/70 animate-spin" />,
icon: <Loader2 className="size-3 text-warning/70 animate-spin" />,
label: "Authenticating",
},
failed: {
icon: <ShieldX className="size-3 text-red-600/70" />,
icon: <ShieldX className="size-3 text-destructive/70" />,
label: "Authentication Failed",
},
rejected: {

View File

@@ -597,14 +597,14 @@ describe("getStatusTooltip", () => {
describe("getStatusColor", () => {
it("should return correct colors for each status", () => {
expect(getStatusColor("discovering")).toBe("text-yellow-500");
expect(getStatusColor("connecting")).toBe("text-yellow-500");
expect(getStatusColor("loading")).toBe("text-yellow-500");
expect(getStatusColor("live")).toBe("text-green-500");
expect(getStatusColor("partial")).toBe("text-yellow-500");
expect(getStatusColor("discovering")).toBe("text-warning");
expect(getStatusColor("connecting")).toBe("text-warning");
expect(getStatusColor("loading")).toBe("text-warning");
expect(getStatusColor("live")).toBe("text-success");
expect(getStatusColor("partial")).toBe("text-warning");
expect(getStatusColor("closed")).toBe("text-muted-foreground");
expect(getStatusColor("offline")).toBe("text-red-500");
expect(getStatusColor("failed")).toBe("text-red-500");
expect(getStatusColor("offline")).toBe("text-destructive");
expect(getStatusColor("failed")).toBe("text-destructive");
});
});
@@ -633,7 +633,7 @@ describe("getRelayStateBadge", () => {
eventCount: 5,
});
expect(badge?.text).toBe("RECEIVING");
expect(badge?.color).toBe("text-green-500");
expect(badge?.color).toBe("text-success");
});
it("should return eose badge", () => {
@@ -644,7 +644,7 @@ describe("getRelayStateBadge", () => {
eventCount: 10,
});
expect(badge?.text).toBe("EOSE");
expect(badge?.color).toBe("text-blue-500");
expect(badge?.color).toBe("text-info");
});
it("should return error badge", () => {
@@ -655,7 +655,7 @@ describe("getRelayStateBadge", () => {
eventCount: 0,
});
expect(badge?.text).toBe("ERROR");
expect(badge?.color).toBe("text-red-500");
expect(badge?.color).toBe("text-destructive");
});
it("should return offline badge for disconnected", () => {

View File

@@ -214,16 +214,16 @@ export function getStatusColor(status: ReqOverallStatus): string {
case "discovering":
case "connecting":
case "loading":
return "text-yellow-500";
return "text-warning";
case "live":
return "text-green-500";
return "text-success";
case "partial":
return "text-yellow-500";
return "text-warning";
case "closed":
return "text-muted-foreground";
case "offline":
case "failed":
return "text-red-500";
return "text-destructive";
}
}
@@ -244,21 +244,21 @@ export function getRelayStateBadge(
// Prioritize subscription state
if (subscriptionState === "receiving") {
return { text: "RECEIVING", color: "text-green-500" };
return { text: "RECEIVING", color: "text-success" };
}
if (subscriptionState === "eose") {
return { text: "EOSE", color: "text-blue-500" };
return { text: "EOSE", color: "text-info" };
}
if (subscriptionState === "error") {
return { text: "ERROR", color: "text-red-500" };
return { text: "ERROR", color: "text-destructive" };
}
// Show connection state if not connected
if (connectionState === "connecting") {
return { text: "CONNECTING", color: "text-yellow-500" };
return { text: "CONNECTING", color: "text-warning" };
}
if (connectionState === "error") {
return { text: "ERROR", color: "text-red-500" };
return { text: "ERROR", color: "text-destructive" };
}
if (connectionState === "disconnected") {
return { text: "OFFLINE", color: "text-muted-foreground" };

154
src/lib/themes/apply.ts Normal file
View File

@@ -0,0 +1,154 @@
import type { Theme } from "./types";
/**
* Apply a theme by setting CSS custom properties on the document root.
* This updates all theme variables at runtime without requiring a page reload.
*/
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
// Apply core colors
root.style.setProperty("--background", theme.colors.background);
root.style.setProperty("--foreground", theme.colors.foreground);
root.style.setProperty("--card", theme.colors.card);
root.style.setProperty("--card-foreground", theme.colors.cardForeground);
root.style.setProperty("--popover", theme.colors.popover);
root.style.setProperty(
"--popover-foreground",
theme.colors.popoverForeground,
);
root.style.setProperty("--primary", theme.colors.primary);
root.style.setProperty(
"--primary-foreground",
theme.colors.primaryForeground,
);
root.style.setProperty("--secondary", theme.colors.secondary);
root.style.setProperty(
"--secondary-foreground",
theme.colors.secondaryForeground,
);
root.style.setProperty("--accent", theme.colors.accent);
root.style.setProperty("--accent-foreground", theme.colors.accentForeground);
root.style.setProperty("--muted", theme.colors.muted);
root.style.setProperty("--muted-foreground", theme.colors.mutedForeground);
root.style.setProperty("--destructive", theme.colors.destructive);
root.style.setProperty(
"--destructive-foreground",
theme.colors.destructiveForeground,
);
root.style.setProperty("--border", theme.colors.border);
root.style.setProperty("--input", theme.colors.input);
root.style.setProperty("--ring", theme.colors.ring);
// Status colors
root.style.setProperty("--success", theme.colors.success);
root.style.setProperty("--warning", theme.colors.warning);
root.style.setProperty("--info", theme.colors.info);
// Nostr-specific colors
root.style.setProperty("--zap", theme.colors.zap);
root.style.setProperty("--live", theme.colors.live);
// UI highlight color
root.style.setProperty("--highlight", theme.colors.highlight);
// Syntax highlighting
root.style.setProperty("--syntax-comment", theme.syntax.comment);
root.style.setProperty("--syntax-punctuation", theme.syntax.punctuation);
root.style.setProperty("--syntax-property", theme.syntax.property);
root.style.setProperty("--syntax-string", theme.syntax.string);
root.style.setProperty("--syntax-keyword", theme.syntax.keyword);
root.style.setProperty("--syntax-function", theme.syntax.function);
root.style.setProperty("--syntax-variable", theme.syntax.variable);
root.style.setProperty("--syntax-operator", theme.syntax.operator);
// Diff colors
root.style.setProperty("--diff-inserted", theme.syntax.diffInserted);
root.style.setProperty("--diff-inserted-bg", theme.syntax.diffInsertedBg);
root.style.setProperty("--diff-deleted", theme.syntax.diffDeleted);
root.style.setProperty("--diff-deleted-bg", theme.syntax.diffDeletedBg);
root.style.setProperty("--diff-meta", theme.syntax.diffMeta);
root.style.setProperty("--diff-meta-bg", theme.syntax.diffMetaBg);
// Scrollbar
root.style.setProperty("--scrollbar-thumb", theme.scrollbar.thumb);
root.style.setProperty("--scrollbar-thumb-hover", theme.scrollbar.thumbHover);
root.style.setProperty("--scrollbar-track", theme.scrollbar.track);
// Gradient
root.style.setProperty("--gradient-1", theme.gradient.color1);
root.style.setProperty("--gradient-2", theme.gradient.color2);
root.style.setProperty("--gradient-3", theme.gradient.color3);
root.style.setProperty("--gradient-4", theme.gradient.color4);
}
/**
* Get all CSS variable names used by the theme system.
* Useful for debugging or documentation.
*/
export function getThemeVariables(): string[] {
return [
// Core colors
"--background",
"--foreground",
"--card",
"--card-foreground",
"--popover",
"--popover-foreground",
"--primary",
"--primary-foreground",
"--secondary",
"--secondary-foreground",
"--accent",
"--accent-foreground",
"--muted",
"--muted-foreground",
"--destructive",
"--destructive-foreground",
"--border",
"--input",
"--ring",
// Status
"--success",
"--warning",
"--info",
// Nostr-specific
"--zap",
"--live",
// UI highlight
"--highlight",
// Syntax
"--syntax-comment",
"--syntax-punctuation",
"--syntax-property",
"--syntax-string",
"--syntax-keyword",
"--syntax-function",
"--syntax-variable",
"--syntax-operator",
// Diff
"--diff-inserted",
"--diff-inserted-bg",
"--diff-deleted",
"--diff-deleted-bg",
"--diff-meta",
"--diff-meta-bg",
// Scrollbar
"--scrollbar-thumb",
"--scrollbar-thumb-hover",
"--scrollbar-track",
// Gradient
"--gradient-1",
"--gradient-2",
"--gradient-3",
"--gradient-4",
];
}

View File

@@ -0,0 +1,85 @@
import type { Theme } from "../types";
/**
* Dark theme - the original Grimoire theme
* Deep blue-black background with bright purple accent
*/
export const darkTheme: Theme = {
id: "dark",
name: "Dark",
description: "The original Grimoire dark theme",
colors: {
background: "222.2 84% 4.9%",
foreground: "210 40% 98%",
card: "222.2 84% 4.9%",
cardForeground: "210 40% 98%",
popover: "222.2 84% 4.9%",
popoverForeground: "210 40% 98%",
primary: "210 40% 98%",
primaryForeground: "222.2 47.4% 11.2%",
secondary: "217.2 32.6% 17.5%",
secondaryForeground: "210 40% 98%",
accent: "270 100% 70%",
accentForeground: "222.2 84% 4.9%",
muted: "217.2 32.6% 17.5%",
mutedForeground: "215 20.2% 70%",
destructive: "0 62.8% 30.6%",
destructiveForeground: "210 40% 98%",
border: "217.2 32.6% 17.5%",
input: "217.2 32.6% 17.5%",
ring: "212.7 26.8% 83.9%",
// Status colors
success: "142 76% 36%",
warning: "45 93% 47%",
info: "199 89% 48%",
// Nostr-specific colors
zap: "45 93% 58%", // Gold/yellow for zaps
live: "0 72% 51%", // Red for live indicator
// UI highlight (active user, self-references)
highlight: "27 96% 61%", // orange-400 (original color)
},
syntax: {
comment: "215 20.2% 70%",
punctuation: "210 40% 70%",
property: "210 40% 98%",
string: "215 20.2% 70%",
keyword: "210 40% 98%",
function: "210 40% 98%",
variable: "210 40% 98%",
operator: "210 40% 98%",
// Diff colors (converted from hardcoded RGB)
diffInserted: "134 60% 76%",
diffInsertedBg: "145 63% 42% / 0.1",
diffDeleted: "0 100% 76%",
diffDeletedBg: "0 100% 60% / 0.1",
diffMeta: "190 77% 70%",
diffMetaBg: "190 77% 70% / 0.08",
},
scrollbar: {
thumb: "0 0% 100% / 0.2",
thumbHover: "0 0% 100% / 0.3",
track: "0 0% 0% / 0",
},
gradient: {
color1: "250 204 21", // yellow-400
color2: "251 146 60", // orange-400
color3: "168 85 247", // purple-500
color4: "34 211 238", // cyan-400
},
};

View File

@@ -0,0 +1,23 @@
export { darkTheme } from "./dark";
export { lightTheme } from "./light";
export { plan9Theme } from "./plan9";
import { darkTheme } from "./dark";
import { lightTheme } from "./light";
import { plan9Theme } from "./plan9";
import type { Theme, BuiltinThemeId } from "../types";
/** Map of all built-in themes by ID */
export const builtinThemes: Record<BuiltinThemeId, Theme> = {
dark: darkTheme,
light: lightTheme,
plan9: plan9Theme,
};
/** Array of all built-in themes for iteration */
export const builtinThemeList: Theme[] = [darkTheme, lightTheme, plan9Theme];
/** Get a built-in theme by ID */
export function getBuiltinTheme(id: BuiltinThemeId): Theme {
return builtinThemes[id];
}

View File

@@ -0,0 +1,85 @@
import type { Theme } from "../types";
/**
* Light theme - clean white background
* Based on the original shadcn/ui light mode values
*/
export const lightTheme: Theme = {
id: "light",
name: "Light",
description: "Clean light theme for daytime use",
colors: {
background: "0 0% 100%",
foreground: "222.2 84% 4.9%",
card: "0 0% 100%",
cardForeground: "222.2 84% 4.9%",
popover: "0 0% 100%",
popoverForeground: "222.2 84% 4.9%",
primary: "222.2 47.4% 11.2%",
primaryForeground: "210 40% 98%",
secondary: "210 40% 96.1%",
secondaryForeground: "222.2 47.4% 11.2%",
accent: "270 70% 55%",
accentForeground: "0 0% 100%",
muted: "210 40% 96.1%",
mutedForeground: "215.4 16.3% 40%",
destructive: "0 72% 51%",
destructiveForeground: "0 0% 100%",
border: "214.3 31.8% 85%",
input: "214.3 31.8% 91.4%",
ring: "222.2 84% 4.9%",
// Status colors (darker for better contrast)
success: "142 70% 30%",
warning: "38 92% 40%",
info: "199 80% 40%",
// Nostr-specific colors (darker for light background)
zap: "45 93% 40%", // Darker gold for contrast on light
live: "0 72% 45%", // Dark red for live indicator
// UI highlight (darker for light background)
highlight: "25 90% 35%", // Dark amber/brown
},
syntax: {
comment: "215.4 16.3% 46.9%",
punctuation: "222.2 84% 30%",
property: "222.2 47.4% 11.2%",
string: "142 60% 30%",
keyword: "270 80% 50%",
function: "222.2 47.4% 11.2%",
variable: "222.2 84% 4.9%",
operator: "222.2 84% 20%",
// Diff colors
diffInserted: "142 60% 30%",
diffInsertedBg: "142 60% 50% / 0.15",
diffDeleted: "0 70% 45%",
diffDeletedBg: "0 70% 50% / 0.15",
diffMeta: "199 80% 40%",
diffMetaBg: "199 80% 50% / 0.1",
},
scrollbar: {
thumb: "222.2 84% 4.9% / 0.2",
thumbHover: "222.2 84% 4.9% / 0.3",
track: "0 0% 0% / 0",
},
gradient: {
color1: "161 98 7", // yellow-700 (darker for light bg)
color2: "154 52 18", // orange-800 (even darker)
color3: "126 34 206", // purple-700
color4: "8 145 178", // cyan-600
},
};

View File

@@ -0,0 +1,101 @@
import type { Theme } from "../types";
/**
* Plan 9 theme - inspired by the Plan 9 from Bell Labs operating system
*
* Characteristics:
* - Pale yellow/cream backgrounds (#ffffe0)
* - Light blue-green window chrome (#eaffea)
* - Black text for high contrast
* - Purple accents for good contrast on pale yellow
* - Muted blue interactive elements
*/
export const plan9Theme: Theme = {
id: "plan9",
name: "Plan 9",
description: "Inspired by Plan 9 from Bell Labs",
colors: {
// Characteristic pale yellow background
background: "60 100% 94%", // #ffffe0
foreground: "0 0% 0%", // Pure black
// Window chrome - pale green (acme editor style)
card: "120 100% 95%", // #eaffea
cardForeground: "0 0% 0%",
popover: "60 100% 97%",
popoverForeground: "0 0% 0%",
// Muted blue for interactive elements (subtler than before)
primary: "220 50% 35%",
primaryForeground: "60 100% 96%",
// Muted green secondary
secondary: "120 30% 88%",
secondaryForeground: "0 0% 0%",
// Purple accent (good contrast on pale yellow)
accent: "280 60% 45%",
accentForeground: "0 0% 100%",
// Muted yellow for subdued elements
muted: "60 30% 88%",
mutedForeground: "0 0% 25%",
// Red for destructive
destructive: "0 70% 40%",
destructiveForeground: "0 0% 100%",
// Subtle borders
border: "60 20% 75%",
input: "60 30% 92%",
ring: "220 50% 35%",
// Status colors (darker for contrast)
success: "120 60% 25%",
warning: "35 90% 35%",
info: "200 70% 35%",
// Nostr-specific colors (dark for contrast on pale yellow)
zap: "35 90% 35%", // Dark amber/gold for zaps
live: "0 70% 40%", // Dark red for live indicator
// UI highlight (dark for contrast on pale yellow)
highlight: "25 85% 30%", // Dark brown/amber
},
syntax: {
// Acme-inspired syntax colors
comment: "0 0% 45%", // Gray
punctuation: "0 0% 25%", // Dark gray
property: "220 50% 35%", // Muted blue
string: "120 60% 28%", // Forest green
keyword: "280 50% 35%", // Muted purple
function: "220 50% 35%", // Muted blue
variable: "0 0% 0%", // Black
operator: "0 0% 15%", // Near black
// Diff colors - subtle on yellow background
diffInserted: "120 60% 25%",
diffInsertedBg: "120 40% 85%",
diffDeleted: "0 60% 40%",
diffDeletedBg: "0 40% 90%",
diffMeta: "200 50% 35%",
diffMetaBg: "200 40% 88%",
},
scrollbar: {
thumb: "60 20% 70%",
thumbHover: "60 25% 60%",
track: "60 30% 92%",
},
gradient: {
// Darker gradient for contrast on pale yellow background
color1: "120 90 15", // Darker olive/mustard
color2: "140 60 25", // Darker burnt orange
color3: "80 50 140", // Dark muted purple
color4: "30 120 130", // Dark teal
},
};

200
src/lib/themes/context.tsx Normal file
View File

@@ -0,0 +1,200 @@
import * as React from "react";
import type { Theme, BuiltinThemeId } from "./types";
import { isBuiltinTheme } from "./types";
import { builtinThemes, builtinThemeList } from "./builtin";
import { applyTheme } from "./apply";
const STORAGE_KEY = "grimoire-theme";
const DEFAULT_THEME_ID: BuiltinThemeId = "dark";
interface ThemeContextValue {
/** Current active theme */
theme: Theme;
/** Current theme ID */
themeId: string;
/** Set theme by ID */
setTheme: (id: string) => void;
/** List of all available themes (builtin + custom) */
availableThemes: Theme[];
/** Custom themes added by user */
customThemes: Theme[];
/** Add a custom theme */
addCustomTheme: (theme: Theme) => void;
/** Remove a custom theme by ID */
removeCustomTheme: (id: string) => void;
}
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
/**
* Hook to access theme context
* Must be used within a ThemeProvider
*/
export function useTheme(): ThemeContextValue {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
/**
* Get the saved theme ID from localStorage
*/
function getSavedThemeId(): string {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const data = JSON.parse(saved);
return data.themeId || DEFAULT_THEME_ID;
}
} catch {
// Ignore parse errors
}
return DEFAULT_THEME_ID;
}
/**
* Get saved custom themes from localStorage
*/
function getSavedCustomThemes(): Theme[] {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const data = JSON.parse(saved);
return data.customThemes || [];
}
} catch {
// Ignore parse errors
}
return [];
}
/**
* Save theme data to localStorage
*/
function saveThemeData(themeId: string, customThemes: Theme[]): void {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ themeId, customThemes }),
);
} catch {
// Ignore storage errors (quota exceeded, etc.)
}
}
/**
* Find a theme by ID from builtin and custom themes
*/
function findTheme(id: string, customThemes: Theme[]): Theme | undefined {
if (isBuiltinTheme(id)) {
return builtinThemes[id];
}
return customThemes.find((t) => t.id === id);
}
interface ThemeProviderProps {
children: React.ReactNode;
/** Default theme ID (overrides localStorage on first render) */
defaultTheme?: string;
}
/**
* Theme provider component
* Manages theme state, persistence, and CSS variable application
*/
export function ThemeProvider({
children,
defaultTheme,
}: ThemeProviderProps): React.ReactElement {
// Initialize from localStorage, falling back to defaultTheme prop or DEFAULT_THEME_ID
const [themeId, setThemeIdState] = React.useState<string>(() => {
const saved = getSavedThemeId();
// Only use defaultTheme if nothing is saved (saved returns DEFAULT_THEME_ID when empty)
// Check localStorage directly to see if user has explicitly chosen a theme
const hasExplicitSave = localStorage.getItem(STORAGE_KEY) !== null;
if (hasExplicitSave) {
return saved;
}
return defaultTheme || DEFAULT_THEME_ID;
});
const [customThemes, setCustomThemes] = React.useState<Theme[]>(() => {
return getSavedCustomThemes();
});
// Resolve current theme
const theme = React.useMemo(() => {
return findTheme(themeId, customThemes) || builtinThemes[DEFAULT_THEME_ID];
}, [themeId, customThemes]);
// Apply theme on mount and when theme changes
React.useEffect(() => {
applyTheme(theme);
}, [theme]);
// Save to localStorage when theme changes
React.useEffect(() => {
saveThemeData(themeId, customThemes);
}, [themeId, customThemes]);
const setTheme = React.useCallback((id: string) => {
setThemeIdState(id);
}, []);
const addCustomTheme = React.useCallback((newTheme: Theme) => {
setCustomThemes((prev) => {
// Replace if exists, otherwise add
const existing = prev.findIndex((t) => t.id === newTheme.id);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = newTheme;
return updated;
}
return [...prev, newTheme];
});
}, []);
const removeCustomTheme = React.useCallback(
(id: string) => {
setCustomThemes((prev) => prev.filter((t) => t.id !== id));
// If removing current theme, switch to default
if (themeId === id) {
setThemeIdState(DEFAULT_THEME_ID);
}
},
[themeId],
);
const availableThemes = React.useMemo(() => {
return [...builtinThemeList, ...customThemes];
}, [customThemes]);
const contextValue = React.useMemo<ThemeContextValue>(
() => ({
theme,
themeId,
setTheme,
availableThemes,
customThemes,
addCustomTheme,
removeCustomTheme,
}),
[
theme,
themeId,
setTheme,
availableThemes,
customThemes,
addCustomTheme,
removeCustomTheme,
],
);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}

29
src/lib/themes/index.ts Normal file
View File

@@ -0,0 +1,29 @@
// Types
export type {
Theme,
ThemeColors,
ThemeSyntax,
ThemeScrollbar,
ThemeGradient,
ThemeMeta,
HSLValue,
RGBValue,
BuiltinThemeId,
} from "./types";
export { isBuiltinTheme } from "./types";
// Built-in themes
export {
darkTheme,
lightTheme,
plan9Theme,
builtinThemes,
builtinThemeList,
getBuiltinTheme,
} from "./builtin";
// Theme application
export { applyTheme, getThemeVariables } from "./apply";
// Context and hooks
export { ThemeProvider, useTheme } from "./context";

153
src/lib/themes/types.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* Theme System Types
*
* All color values are HSL without the hsl() wrapper.
* Format: "hue saturation% lightness%" (e.g., "220 70% 50%")
*
* For Nostr publishing:
* - kind: 30078 (NIP-78 arbitrary app data)
* - d tag: "grimoire-theme"
* - name tag: theme display name
*/
/** HSL color value without wrapper (e.g., "220 70% 50%") */
export type HSLValue = string;
/** RGB color for gradients (e.g., "250 204 21") */
export type RGBValue = string;
/** Core semantic colors for UI components */
export interface ThemeColors {
// Core backgrounds and text
background: HSLValue;
foreground: HSLValue;
// Card/panel surfaces
card: HSLValue;
cardForeground: HSLValue;
// Popover/dropdown surfaces
popover: HSLValue;
popoverForeground: HSLValue;
// Primary interactive elements
primary: HSLValue;
primaryForeground: HSLValue;
// Secondary interactive elements
secondary: HSLValue;
secondaryForeground: HSLValue;
// Accent/highlight color
accent: HSLValue;
accentForeground: HSLValue;
// Subdued/muted elements
muted: HSLValue;
mutedForeground: HSLValue;
// Destructive/error states
destructive: HSLValue;
destructiveForeground: HSLValue;
// Form elements
border: HSLValue;
input: HSLValue;
ring: HSLValue;
// Status indicators (replacing hardcoded Tailwind colors)
success: HSLValue;
warning: HSLValue;
info: HSLValue;
// Nostr-specific colors
zap: HSLValue; // Lightning zaps (typically yellow/gold)
live: HSLValue; // Live streaming indicator (typically red)
// UI highlight color (for active user, self-references, etc.)
highlight: HSLValue;
}
/** Syntax highlighting colors for code blocks */
export interface ThemeSyntax {
// General tokens
comment: HSLValue;
punctuation: HSLValue;
property: HSLValue;
string: HSLValue;
keyword: HSLValue;
function: HSLValue;
variable: HSLValue;
operator: HSLValue;
// Diff-specific tokens
diffInserted: HSLValue;
diffInsertedBg: HSLValue;
diffDeleted: HSLValue;
diffDeletedBg: HSLValue;
diffMeta: HSLValue;
diffMetaBg: HSLValue;
}
/** Scrollbar styling */
export interface ThemeScrollbar {
thumb: HSLValue;
thumbHover: HSLValue;
track: HSLValue;
}
/** Gradient colors (RGB values for CSS rgb() function) */
export interface ThemeGradient {
// Grimoire brand gradient (4 color stops)
color1: RGBValue; // Top - yellow
color2: RGBValue; // Upper-middle - orange
color3: RGBValue; // Lower-middle - purple
color4: RGBValue; // Bottom - cyan
}
/** Complete theme definition */
export interface Theme {
/** Unique identifier (e.g., "plan9", "dark", "light") */
id: string;
/** Display name shown in UI */
name: string;
/** Theme author (npub or display name) */
author?: string;
/** Semantic version */
version?: string;
/** Theme description */
description?: string;
/** Core UI colors */
colors: ThemeColors;
/** Syntax highlighting colors */
syntax: ThemeSyntax;
/** Scrollbar colors */
scrollbar: ThemeScrollbar;
/** Gradient colors */
gradient: ThemeGradient;
}
/** Theme metadata for listings (without full color data) */
export interface ThemeMeta {
id: string;
name: string;
author?: string;
version?: string;
description?: string;
}
/** Built-in theme IDs */
export type BuiltinThemeId = "dark" | "light" | "plan9";
/** Check if a theme ID is a built-in theme */
export function isBuiltinTheme(id: string): id is BuiltinThemeId {
return id === "dark" || id === "light" || id === "plan9";
}

View File

@@ -8,31 +8,30 @@ import { Toaster } from "sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { initializeErrorHandling } from "./lib/error-handler";
// Add dark class to html element for default dark theme
document.documentElement.classList.add("dark");
import { ThemeProvider } from "./lib/themes";
// Initialize global error handling
initializeErrorHandling();
createRoot(document.getElementById("root")!).render(
<ErrorBoundary level="app">
<EventStoreProvider eventStore={eventStore}>
<TooltipProvider>
<Toaster
position="top-center"
theme="dark"
toastOptions={{
style: {
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
borderRadius: 0,
},
}}
/>
<Root />
</TooltipProvider>
</EventStoreProvider>
<ThemeProvider defaultTheme="dark">
<EventStoreProvider eventStore={eventStore}>
<TooltipProvider>
<Toaster
position="top-center"
toastOptions={{
style: {
background: "hsl(var(--background))",
color: "hsl(var(--foreground))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
},
}}
/>
<Root />
</TooltipProvider>
</EventStoreProvider>
</ThemeProvider>
</ErrorBoundary>,
);

View File

@@ -1,4 +1,4 @@
/* Grimoire Prism Theme - Matches dark theme using CSS variables */
/* Grimoire Prism Theme - Uses CSS theme variables */
code[class*="language-"],
pre[class*="language-"] {
@@ -19,8 +19,8 @@ pre[class*="language-"] {
/* Deleted lines (red) - subtle background, no strikethrough */
.token.deleted {
color: #ff8787;
background: rgba(255, 59, 48, 0.1);
color: hsl(var(--diff-deleted));
background: hsl(var(--diff-deleted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
@@ -28,8 +28,8 @@ pre[class*="language-"] {
/* Added lines (green) - subtle background */
.token.inserted {
color: #69db7c;
background: rgba(52, 199, 89, 0.1);
color: hsl(var(--diff-inserted));
background: hsl(var(--diff-inserted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
@@ -38,8 +38,8 @@ pre[class*="language-"] {
/* Hunk headers (@@ -1,5 +1,7 @@) - cyan/blue */
.token.diff.coord,
.token.coord {
color: #66d9ef;
background: rgba(102, 217, 239, 0.08);
color: hsl(var(--diff-meta));
background: hsl(var(--diff-meta-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
@@ -66,11 +66,11 @@ pre[class*="language-"] {
.token.prolog,
.token.doctype,
.token.cdata {
color: hsl(var(--muted-foreground));
color: hsl(var(--syntax-comment));
}
.token.punctuation {
color: hsl(var(--foreground) / 0.7);
color: hsl(var(--syntax-punctuation));
}
.token.property,
@@ -79,7 +79,7 @@ pre[class*="language-"] {
.token.number,
.token.constant,
.token.symbol {
color: hsl(var(--primary));
color: hsl(var(--syntax-property));
}
.token.selector,
@@ -87,8 +87,7 @@ pre[class*="language-"] {
.token.string,
.token.char,
.token.builtin {
color: hsl(var(--muted-foreground));
font-weight: 500;
color: hsl(var(--syntax-string));
}
.token.operator,
@@ -96,25 +95,25 @@ pre[class*="language-"] {
.token.url,
.language-css .token.string,
.style .token.string {
color: hsl(var(--foreground));
color: hsl(var(--syntax-operator));
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: hsl(var(--primary));
color: hsl(var(--syntax-keyword));
}
.token.function,
.token.class-name {
color: hsl(var(--primary));
color: hsl(var(--syntax-function));
font-weight: bold;
}
.token.regex,
.token.important,
.token.variable {
color: hsl(var(--primary));
color: hsl(var(--syntax-variable));
}
.token.important,