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
This commit is contained in:
Claude
2026-01-14 16:48:03 +00:00
parent 16764e1aca
commit 2e20d8ed57
12 changed files with 924 additions and 66 deletions

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,43 @@
--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%;
/* 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 +109,40 @@
--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%;
/* 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 +167,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 +178,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));
}

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

@@ -0,0 +1,150 @@
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);
// 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);
// Layout
root.style.setProperty("--radius", theme.radius);
// Remove the dark class management - we now handle this via CSS variables directly
// The dark class is no longer needed as themes apply their own color values
}
/**
* 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",
// 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",
// Layout
"--radius",
];
}

View File

@@ -0,0 +1,80 @@
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%",
},
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
},
radius: "0.5rem",
};

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,80 @@
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 80% 60%",
accentForeground: "0 0% 100%",
muted: "210 40% 96.1%",
mutedForeground: "215.4 16.3% 46.9%",
destructive: "0 84.2% 60.2%",
destructiveForeground: "210 40% 98%",
border: "214.3 31.8% 91.4%",
input: "214.3 31.8% 91.4%",
ring: "222.2 84% 4.9%",
// Status colors
success: "142 76% 36%",
warning: "45 93% 47%",
info: "199 89% 48%",
},
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: "234 179 8", // yellow-500 (darker for light bg)
color2: "249 115 22", // orange-500
color3: "147 51 234", // purple-600
color4: "6 182 212", // cyan-500
},
radius: "0.5rem",
};

View File

@@ -0,0 +1,98 @@
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
* - Bright yellow selections
* - Dark blue accents
* - No rounded corners (squared aesthetic)
*/
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%",
// Dark blue for interactive elements
primary: "220 100% 25%",
primaryForeground: "60 100% 94%",
// Muted green secondary
secondary: "120 30% 88%",
secondaryForeground: "0 0% 0%",
// Bright yellow accent (Plan9 signature selection color)
accent: "60 100% 50%",
accentForeground: "0 0% 0%",
// Muted yellow for subdued elements
muted: "60 30% 88%",
mutedForeground: "0 0% 35%",
// Red for destructive
destructive: "0 70% 45%",
destructiveForeground: "0 0% 100%",
// Dark blue borders
border: "220 40% 50%",
input: "60 30% 92%",
ring: "220 100% 25%",
// Status colors
success: "120 60% 30%",
warning: "45 90% 45%",
info: "200 80% 40%",
},
syntax: {
// Acme-inspired syntax colors
comment: "0 0% 45%", // Gray
punctuation: "0 0% 25%", // Dark gray
property: "220 100% 25%", // Dark blue
string: "120 60% 28%", // Forest green
keyword: "280 60% 35%", // Purple
function: "220 100% 25%", // Dark blue
variable: "0 0% 0%", // Black
operator: "0 0% 15%", // Near black
// Diff colors - subtle on yellow background
diffInserted: "120 60% 25%",
diffInsertedBg: "120 50% 85%",
diffDeleted: "0 65% 40%",
diffDeletedBg: "0 50% 90%",
diffMeta: "200 70% 35%",
diffMetaBg: "200 50% 88%",
},
scrollbar: {
thumb: "220 30% 55%",
thumbHover: "220 40% 45%",
track: "60 30% 90%",
},
gradient: {
// Muted gradient for Plan9 aesthetic
color1: "180 140 20", // Olive/mustard
color2: "200 120 50", // Burnt orange
color3: "100 60 180", // Muted purple
color4: "40 160 180", // Teal
},
// Plan9 has no rounded corners - everything is squared
radius: "0",
};

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

@@ -0,0 +1,193 @@
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 or default
const [themeId, setThemeIdState] = React.useState<string>(() => {
return defaultTheme || getSavedThemeId();
});
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";

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

@@ -0,0 +1,149 @@
/**
* 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;
}
/** 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;
/** Border radius base value (e.g., "0.5rem", "0") */
radius: string;
}
/** 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,

View File

@@ -65,13 +65,10 @@ export default {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
// Status colors
success: "hsl(var(--success))",
warning: "hsl(var(--warning))",
info: "hsl(var(--info))",
},
},
},