mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
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:
107
src/index.css
107
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,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
150
src/lib/themes/apply.ts
Normal 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",
|
||||
];
|
||||
}
|
||||
80
src/lib/themes/builtin/dark.ts
Normal file
80
src/lib/themes/builtin/dark.ts
Normal 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",
|
||||
};
|
||||
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];
|
||||
}
|
||||
80
src/lib/themes/builtin/light.ts
Normal file
80
src/lib/themes/builtin/light.ts
Normal 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",
|
||||
};
|
||||
98
src/lib/themes/builtin/plan9.ts
Normal file
98
src/lib/themes/builtin/plan9.ts
Normal 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
193
src/lib/themes/context.tsx
Normal 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
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";
|
||||
149
src/lib/themes/types.ts
Normal file
149
src/lib/themes/types.ts
Normal 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";
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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))",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user