mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
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:
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
121
src/index.css
121
src/index.css
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
154
src/lib/themes/apply.ts
Normal 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",
|
||||
];
|
||||
}
|
||||
85
src/lib/themes/builtin/dark.ts
Normal file
85
src/lib/themes/builtin/dark.ts
Normal 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
|
||||
},
|
||||
};
|
||||
23
src/lib/themes/builtin/index.ts
Normal file
23
src/lib/themes/builtin/index.ts
Normal 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];
|
||||
}
|
||||
85
src/lib/themes/builtin/light.ts
Normal file
85
src/lib/themes/builtin/light.ts
Normal 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
|
||||
},
|
||||
};
|
||||
101
src/lib/themes/builtin/plan9.ts
Normal file
101
src/lib/themes/builtin/plan9.ts
Normal 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
200
src/lib/themes/context.tsx
Normal 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
29
src/lib/themes/index.ts
Normal 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
153
src/lib/themes/types.ts
Normal 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";
|
||||
}
|
||||
39
src/main.tsx
39
src/main.tsx
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user