Merge pull request #45 from Lum1104/feat/theme-system

feat(dashboard): add curated theme preset system with accent customization
This commit is contained in:
Yuxiang Lin
2026-03-27 10:20:33 +08:00
committed by GitHub
Unverified
19 changed files with 647 additions and 87 deletions
@@ -66,12 +66,19 @@ export interface KnowledgeGraph {
tour: TourStep[];
}
// Theme configuration (for dashboard customization)
export interface ThemeConfig {
presetId: string;
accentId: string;
}
// AnalysisMeta (for persistence)
export interface AnalysisMeta {
lastAnalyzedAt: string;
gitCommitHash: string;
version: string;
analyzedFiles: number;
theme?: ThemeConfig;
}
// Plugin interfaces
@@ -15,6 +15,9 @@ import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp";
import WarningBanner from "./components/WarningBanner";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
import { ThemeProvider } from "./themes/index.ts";
import { ThemePicker } from "./components/ThemePicker.tsx";
import type { ThemeConfig } from "./themes/index.ts";
function App() {
const graph = useDashboardStore((s) => s.graph);
@@ -28,6 +31,16 @@ function App() {
const [loadError, setLoadError] = useState<string | null>(null);
const [graphIssues, setGraphIssues] = useState<GraphIssue[]>([]);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
useEffect(() => {
fetch("/meta.json")
.then((r) => (r.ok ? r.json() : null))
.then((meta) => {
if (meta?.theme) setMetaTheme(meta.theme);
})
.catch(() => {});
}, []);
// Define keyboard shortcuts
const shortcuts = useMemo<KeyboardShortcut[]>(
@@ -185,6 +198,7 @@ function App() {
);
return (
<ThemeProvider metaTheme={metaTheme}>
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Header */}
<header className="flex items-center justify-between px-5 py-3 bg-surface border-b border-border-subtle shrink-0">
@@ -198,9 +212,10 @@ function App() {
<div className="flex items-center gap-4">
<DiffToggle />
<LayerLegend />
<ThemePicker />
<button
onClick={() => setShowKeyboardHelp(true)}
className="text-text-muted hover:text-gold transition-colors"
className="text-text-muted hover:text-accent transition-colors"
title="Keyboard shortcuts (Shift + ?)"
>
<svg
@@ -278,6 +293,7 @@ function App() {
/>
)}
</div>
</ThemeProvider>
);
}
@@ -27,8 +27,8 @@ export default function CodeViewer() {
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border"
style={{
color: "var(--color-node-file)",
borderColor: "rgba(74,124,155,0.3)",
backgroundColor: "rgba(74,124,155,0.1)",
borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)",
backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)",
}}
>
{node.type}
@@ -56,14 +56,14 @@ export default function CodeViewer() {
<div className="flex-1 overflow-auto p-5">
{/* Summary */}
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Summary</h4>
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Summary</h4>
<p className="text-sm text-text-secondary leading-relaxed">{node.summary}</p>
</div>
{/* Language notes callout */}
{node.languageNotes && (
<div className="mb-4 bg-gold/5 border border-gold/20 rounded-lg p-3">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-1.5">Language Notes</h4>
<div className="mb-4 bg-accent/5 border border-accent/20 rounded-lg p-3">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">Language Notes</h4>
<p className="text-sm text-text-secondary leading-relaxed">{node.languageNotes}</p>
</div>
)}
@@ -71,7 +71,7 @@ export default function CodeViewer() {
{/* Tags */}
{node.tags.length > 0 && (
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Tags</h4>
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{node.tags.map((tag) => (
<span key={tag} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -20,7 +20,7 @@ const typeTextColors: Record<string, string> = {
const complexityColors: Record<string, string> = {
simple: "text-node-function",
moderate: "text-gold-dim",
moderate: "text-accent-dim",
complex: "text-[#c97070]",
};
@@ -51,17 +51,17 @@ function CustomNodeComponent({
let extraClass = "";
if (data.isSelected) {
extraClass = "ring-2 ring-gold node-glow";
extraClass = "ring-2 ring-accent node-glow";
} else if (data.isTourHighlighted) {
extraClass = "ring-2 ring-gold-dim animate-gold-pulse";
extraClass = "ring-2 ring-accent-dim animate-accent-pulse";
} else if (data.isHighlighted) {
const score = data.searchScore ?? 1;
if (score <= 0.1) {
extraClass = "ring-2 ring-gold-bright";
extraClass = "ring-2 ring-accent-bright";
} else if (score <= 0.3) {
extraClass = "ring-2 ring-gold";
extraClass = "ring-2 ring-accent";
} else {
extraClass = "ring-1 ring-gold-dim/60";
extraClass = "ring-1 ring-accent-dim/60";
}
}
@@ -16,6 +16,7 @@ import "@xyflow/react/dist/style.css";
import CustomNode from "./CustomNode";
import type { CustomFlowNode } from "./CustomNode";
import { useDashboardStore } from "../store";
import { useTheme } from "../themes/index.ts";
import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout";
const LAYER_PADDING = 40;
@@ -153,16 +154,16 @@ function buildTopologyData(
style: isImpacted
? {
stroke: sourceInDiff && targetInDiff
? "rgba(224, 82, 82, 0.7)"
: "rgba(212, 160, 48, 0.5)",
? "var(--color-diff-changed)"
: "var(--color-diff-affected)",
strokeWidth: 2.5,
}
: diffMode
? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }
: { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 },
? { stroke: "var(--color-edge-dim)", strokeWidth: 1 }
: { stroke: "var(--color-edge)", strokeWidth: 1.5 },
labelStyle: diffMode && !isImpacted
? { fill: "rgba(163,151,135,0.3)", fontSize: 10 }
: { fill: "#a39787", fontSize: 10 },
? { fill: "var(--color-text-muted)", fontSize: 10 }
: { fill: "var(--color-text-secondary)", fontSize: 10 },
};
});
@@ -262,13 +263,13 @@ function applyLayerGroups(
style: {
width: groupWidth,
height: groupHeight,
backgroundColor: "rgba(212,165,116,0.05)",
backgroundColor: "var(--color-accent-overlay-bg)",
borderRadius: 12,
border: "2px dashed rgba(212,165,116,0.25)",
border: "2px dashed var(--color-accent-overlay-border)",
padding: 8,
fontSize: 13,
fontWeight: 600,
color: "#d4a574",
color: "var(--color-accent)",
},
});
@@ -309,6 +310,7 @@ function GraphViewInner() {
const diffMode = useDashboardStore((s) => s.diffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
const { preset } = useTheme();
const [layouting, setLayouting] = useState(false);
@@ -429,7 +431,7 @@ function GraphViewInner() {
{layouting && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-root/80 rounded-lg">
<div className="text-center">
<div className="inline-block w-8 h-8 border-2 border-gold border-t-transparent rounded-full animate-spin mb-3" />
<div className="inline-block w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-text-secondary text-sm">
Laying out {topoNodes.length.toLocaleString()} nodes...
</p>
@@ -453,13 +455,13 @@ function GraphViewInner() {
fitViewOptions={{ minZoom: 0.01, padding: 0.1 }}
minZoom={0.01}
maxZoom={2}
colorMode="dark"
colorMode={preset.isDark ? "dark" : "light"}
>
<Background variant={BackgroundVariant.Dots} color="rgba(212,165,116,0.15)" gap={20} size={1} />
<Background variant={BackgroundVariant.Dots} color="var(--color-edge-dot)" gap={20} size={1} />
<Controls />
<MiniMap
nodeColor="#1a1a1a"
maskColor="rgba(10,10,10,0.7)"
nodeColor="var(--color-elevated)"
maskColor="var(--glass-bg)"
className="!bg-surface !border !border-border-subtle"
/>
<TourFitView />
@@ -62,7 +62,7 @@ export default function KeyboardShortcutsHelp({
<div className="p-6 space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gold uppercase tracking-wider mb-3">
<h3 className="text-sm font-semibold text-accent uppercase tracking-wider mb-3">
{category}
</h3>
<div className="space-y-2">
@@ -47,7 +47,7 @@ export default function LayerLegend() {
disabled={!hasLayers}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
showLayers && hasLayers
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: hasLayers
? "bg-elevated text-text-secondary hover:bg-surface"
: "bg-elevated text-text-muted cursor-not-allowed"
@@ -47,13 +47,13 @@ export default function LearnPanel() {
<button
onClick={startTour}
className="w-full mb-4 bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-colors"
className="w-full mb-4 bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-colors"
>
Start Tour
</button>
<div className="space-y-2">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Steps
</h3>
{tourSteps.map((step, i) => (
@@ -61,7 +61,7 @@ export default function LearnPanel() {
key={step.order}
className="flex items-start gap-2 text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle"
>
<span className="text-gold font-mono shrink-0 mt-0.5">
<span className="text-accent font-mono shrink-0 mt-0.5">
{i + 1}.
</span>
<span className="text-text-secondary">{step.title}</span>
@@ -86,7 +86,7 @@ export default function LearnPanel() {
{/* Header with progress counter and exit */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<div className="flex items-center gap-2">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider">
Tour
</h3>
<span className="text-xs text-text-muted">
@@ -104,7 +104,7 @@ export default function LearnPanel() {
{/* Progress bar */}
<div className="h-1 bg-elevated shrink-0">
<div
className="h-full bg-gold transition-all duration-300"
className="h-full bg-accent transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -122,16 +122,16 @@ export default function LearnPanel() {
<p className="mb-1.5 last:mb-0">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-white">{children}</strong>
<strong className="font-semibold text-text-primary">{children}</strong>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<code className="block bg-gray-900 rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
<code className="block bg-elevated rounded px-2 py-1.5 mb-1.5 overflow-x-auto text-[11px] leading-relaxed">
{children}
</code>
) : (
<code className="bg-gray-900 rounded px-1 py-0.5 text-[11px]">
<code className="bg-elevated rounded px-1 py-0.5 text-[11px]">
{children}
</code>
);
@@ -154,8 +154,8 @@ export default function LearnPanel() {
{/* Language lesson */}
{step.languageLesson && (
<div className="bg-gold/5 border border-gold/20 rounded p-3 mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-1.5">
<div className="bg-accent/5 border border-accent/20 rounded p-3 mb-4">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">
Language Lesson
</h4>
<p className="text-sm text-text-secondary leading-relaxed">
@@ -167,7 +167,7 @@ export default function LearnPanel() {
{/* Referenced component pills */}
{step.nodeIds.length > 0 && (
<div className="mb-4">
<h4 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Referenced Components
</h4>
<div className="flex flex-wrap gap-1.5">
@@ -198,7 +198,7 @@ export default function LearnPanel() {
onClick={() => setTourStep(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentTourStep
? "bg-gold"
? "bg-accent"
: "bg-elevated hover:bg-surface"
}`}
aria-label={`Go to step ${i + 1}`}
@@ -217,7 +217,7 @@ export default function LearnPanel() {
</button>
<button
onClick={isLast ? stopTour : nextTourStep}
className="flex-1 text-xs bg-gold/10 border border-gold/30 text-gold py-1.5 rounded-lg hover:bg-gold/20 transition-colors"
className="flex-1 text-xs bg-accent/10 border border-accent/30 text-accent py-1.5 rounded-lg hover:bg-accent/20 transition-colors"
>
{isLast ? "Finish" : "Next"}
</button>
@@ -11,7 +11,7 @@ const typeBadgeColors: Record<string, string> = {
const complexityBadgeColors: Record<string, string> = {
simple: "text-node-function border border-node-function/30 bg-node-function/10",
moderate: "text-gold-dim border border-gold-dim/30 bg-gold-dim/10",
moderate: "text-accent-dim border border-accent-dim/30 bg-accent-dim/10",
complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10",
};
@@ -75,7 +75,7 @@ export default function NodeInfo() {
<div className="mb-4">
<button
onClick={() => setLanguageExpanded(!languageExpanded)}
className="flex items-center gap-1.5 text-xs font-semibold text-gold uppercase tracking-wider mb-2 hover:text-gold-bright transition-colors"
className="flex items-center gap-1.5 text-xs font-semibold text-accent uppercase tracking-wider mb-2 hover:text-accent-bright transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${languageExpanded ? "rotate-90" : ""}`}
@@ -88,7 +88,7 @@ export default function NodeInfo() {
Language Concepts
</button>
{languageExpanded && (
<div className="bg-gold/5 border border-gold/20 rounded-lg p-3">
<div className="bg-accent/5 border border-accent/20 rounded-lg p-3">
<p className="text-sm text-text-secondary leading-relaxed">
{node.languageNotes}
</p>
@@ -99,7 +99,7 @@ export default function NodeInfo() {
{node.tags.length > 0 && (
<div className="mb-4">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Tags
</h3>
<div className="flex flex-wrap gap-1.5">
@@ -117,7 +117,7 @@ export default function NodeInfo() {
{connections.length > 0 && (
<div>
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">
Connections ({connections.length})
</h3>
<div className="space-y-1.5">
@@ -132,7 +132,7 @@ export default function NodeInfo() {
key={i}
className="text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle flex items-center gap-2"
>
<span className="text-gold font-mono">{arrow}</span>
<span className="text-accent font-mono">{arrow}</span>
<span className="text-text-muted">{edge.type}</span>
<span className="text-text-primary truncate">
{otherNode?.name ?? otherId}
@@ -32,7 +32,7 @@ export default function PersonaSelector() {
title={p.description}
className={`px-2.5 py-1 rounded text-[11px] font-medium transition-colors ${
persona === p.id
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary hover:bg-surface"
}`}
>
@@ -30,19 +30,19 @@ export default function ProjectOverview() {
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{nodes.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{nodes.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Nodes</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{edges.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{edges.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Edges</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{layers.length}</div>
<div className="text-2xl font-mono font-medium text-accent">{layers.length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Layers</div>
</div>
<div className="bg-elevated rounded-lg p-3 border border-border-subtle">
<div className="text-2xl font-mono font-medium text-gold">{Object.keys(typeCounts).length}</div>
<div className="text-2xl font-mono font-medium text-accent">{Object.keys(typeCounts).length}</div>
<div className="text-[11px] text-text-muted uppercase tracking-wider mt-1">Types</div>
</div>
</div>
@@ -50,7 +50,7 @@ export default function ProjectOverview() {
{/* Languages */}
{project.languages.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Languages</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Languages</h3>
<div className="flex flex-wrap gap-1.5">
{project.languages.map((lang) => (
<span key={lang} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -64,7 +64,7 @@ export default function ProjectOverview() {
{/* Frameworks */}
{project.frameworks.length > 0 && (
<div className="mb-5">
<h3 className="text-[11px] font-semibold text-gold uppercase tracking-wider mb-2">Frameworks</h3>
<h3 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Frameworks</h3>
<div className="flex flex-wrap gap-1.5">
{project.frameworks.map((fw) => (
<span key={fw} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
@@ -84,7 +84,7 @@ export default function ProjectOverview() {
{hasTour && (
<button
onClick={startTour}
className="w-full bg-gold/10 border border-gold/30 text-gold text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-gold/20 transition-all duration-200"
className="w-full bg-accent/10 border border-accent/30 text-accent text-sm font-medium py-2.5 px-4 rounded-lg hover:bg-accent/20 transition-all duration-200"
>
Start Guided Tour
</button>
@@ -94,14 +94,14 @@ export default function SearchBar() {
onChange={handleInputChange}
onFocus={() => setDropdownOpen(true)}
placeholder="Search nodes by name, summary, or tags..."
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-gold/50 placeholder-text-muted"
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
/>
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5 shrink-0">
<button
onClick={() => setSearchMode("fuzzy")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "fuzzy"
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
@@ -111,7 +111,7 @@ export default function SearchBar() {
onClick={() => setSearchMode("semantic")}
className={`text-[10px] px-1.5 py-0.5 rounded transition-colors ${
searchMode === "semantic"
? "bg-gold/20 text-gold"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
@@ -159,7 +159,7 @@ export default function SearchBar() {
<div className="flex items-center gap-1.5 shrink-0">
<div className="w-16 h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gold rounded-full"
className="h-full bg-accent rounded-full"
style={{ width: `${relevance}%` }}
/>
</div>
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTheme, PRESETS } from "../themes/index.ts";
export function ThemePicker() {
const { config, preset, setPreset, setAccent } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
const handlePreset = useCallback(
(id: string) => {
setPreset(id as Parameters<typeof setPreset>[0]);
},
[setPreset],
);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-text-secondary hover:text-text-primary transition-colors"
title="Change theme"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a7 7 0 0 0 0 14 4 4 0 0 1 0 8 10 10 0 0 0 0-20z" />
<circle cx="8" cy="10" r="1.5" fill="currentColor" />
<circle cx="12" cy="7" r="1.5" fill="currentColor" />
<circle cx="16" cy="10" r="1.5" fill="currentColor" />
</svg>
<span className="hidden sm:inline">Theme</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 w-64 rounded-lg glass-heavy shadow-xl z-50 p-3 space-y-3">
{/* Presets */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
Theme
</div>
<div className="space-y-1">
{PRESETS.map((p) => (
<button
key={p.id}
onClick={() => handlePreset(p.id)}
className={`w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded text-xs transition-colors ${
p.id === config.presetId
? "bg-accent/15 text-accent"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
>
{/* Color preview dots */}
<div className="flex gap-1">
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.root }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{ backgroundColor: p.colors.surface }}
/>
<span
className="w-3 h-3 rounded-full border border-border-subtle"
style={{
backgroundColor:
p.accentSwatches.find((s) => s.id === p.defaultAccentId)?.accent ??
p.accentSwatches[0].accent,
}}
/>
</div>
<span>{p.name}</span>
{p.id === config.presetId && (
<svg
className="ml-auto w-3.5 h-3.5 text-accent"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
</div>
{/* Accent swatches */}
<div>
<div className="text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
Accent Color
</div>
<div className="flex gap-2 flex-wrap">
{preset.accentSwatches.map((swatch) => (
<button
key={swatch.id}
onClick={() => setAccent(swatch.id)}
className={`w-6 h-6 rounded-full transition-transform hover:scale-110 ${
swatch.id === config.accentId
? "ring-2 ring-text-primary ring-offset-1 ring-offset-root"
: ""
}`}
style={{ backgroundColor: swatch.accent }}
title={swatch.name}
/>
))}
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,46 +1,77 @@
@import "tailwindcss";
@theme {
/* Dark luxury color palette */
/* Base */
--color-root: #0a0a0a;
--color-surface: #111111;
--color-elevated: #1a1a1a;
--color-panel: #141414;
/* Gold accent spectrum */
--color-gold: #d4a574;
--color-gold-dim: #c9a96e;
--color-gold-bright: #e8c49a;
/* Accent */
--color-accent: #d4a574;
--color-accent-dim: #c9a96e;
--color-accent-bright: #e8c49a;
/* Text hierarchy */
/* Text */
--color-text-primary: #f5f0eb;
--color-text-secondary: #a39787;
--color-text-muted: #6b5f53;
/* Border tokens */
/* Borders */
--color-border-subtle: rgba(212, 165, 116, 0.12);
--color-border-medium: rgba(212, 165, 116, 0.25);
/* Node type colors (muted, refined) */
/* Node types */
--color-node-file: #4a7c9b;
--color-node-function: #5a9e6f;
--color-node-class: #8b6fb0;
--color-node-module: #c9a06c;
--color-node-concept: #b07a8a;
/* Diff overlay colors */
/* Diff */
--color-diff-changed: #e05252;
--color-diff-affected: #d4a030;
--color-diff-changed-dim: rgba(224, 82, 82, 0.25);
--color-diff-affected-dim: rgba(212, 160, 48, 0.25);
/* Fonts */
/* Glass */
--glass-bg: rgba(20, 20, 20, 0.8);
--glass-bg-heavy: rgba(20, 20, 20, 0.95);
--glass-border: rgba(212, 165, 116, 0.1);
--glass-border-heavy: rgba(212, 165, 116, 0.15);
/* Scrollbar */
--scrollbar-thumb: rgba(212, 165, 116, 0.2);
--scrollbar-thumb-hover: rgba(212, 165, 116, 0.35);
/* Glow */
--glow-accent: rgba(212, 165, 116, 0.15);
--glow-accent-strong: rgba(212, 165, 116, 0.4);
--glow-accent-pulse: rgba(212, 165, 116, 0.6);
/* Edges */
--color-edge: rgba(212, 165, 116, 0.3);
--color-edge-dim: rgba(212, 165, 116, 0.08);
--color-edge-dot: rgba(212, 165, 116, 0.15);
/* Accent overlays */
--color-accent-overlay-bg: rgba(212, 165, 116, 0.05);
--color-accent-overlay-border: rgba(212, 165, 116, 0.25);
/* Kbd */
--kbd-bg: rgba(212, 165, 116, 0.1);
/* Typography */
--font-serif: 'DM Serif Display', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Inter', system-ui, sans-serif;
}
/* Base styles */
html {
transition: background-color 0.2s ease, color 0.2s ease;
}
body {
font-family: var(--font-sans);
background-color: var(--color-root);
@@ -65,15 +96,15 @@ body {
/* Glass utility */
.glass {
background: rgba(20, 20, 20, 0.8);
border: 1px solid rgba(212, 165, 116, 0.1);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-heavy {
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(212, 165, 116, 0.15);
background: var(--glass-bg-heavy);
border: 1px solid var(--glass-border-heavy);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
@@ -89,11 +120,11 @@ body {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-gold);
background: rgba(212, 165, 116, 0.1);
border: 1px solid rgba(212, 165, 116, 0.3);
color: var(--color-accent);
background: var(--kbd-bg);
border: 1px solid var(--color-border-medium);
border-radius: 0.25rem;
box-shadow: 0 1px 0 rgba(212, 165, 116, 0.2);
box-shadow: 0 1px 0 var(--scrollbar-thumb);
}
/* Animation keyframes */
@@ -117,12 +148,12 @@ body {
}
}
@keyframes goldPulse {
@keyframes accentPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(212, 165, 116, 0.4);
box-shadow: 0 0 8px var(--glow-accent-strong);
}
50% {
box-shadow: 0 0 20px 4px rgba(212, 165, 116, 0.15);
box-shadow: 0 0 20px var(--glow-accent-pulse);
}
}
@@ -135,13 +166,13 @@ body {
animation: slideUp 0.3s ease-out forwards;
}
.animate-gold-pulse {
animation: goldPulse 2s ease-in-out infinite;
.animate-accent-pulse {
animation: accentPulse 2s ease-in-out infinite;
}
/* Node selection glow */
.node-glow {
box-shadow: 0 0 20px rgba(212, 165, 116, 0.15);
box-shadow: 0 0 20px var(--glow-accent);
}
/* Diff overlay glow effects */
@@ -169,14 +200,37 @@ body {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(212, 165, 116, 0.2);
border-radius: 3px;
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(212, 165, 116, 0.35);
background: var(--scrollbar-thumb-hover);
}
/* Override React Flow dark theme */
.react-flow__background {
background-color: var(--color-root) !important;
}
/* Light theme overrides */
[data-theme="light"] {
color-scheme: light;
}
[data-theme="light"] .diff-faded {
opacity: 0.35;
}
[data-theme="light"] ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
}
[data-theme="light"] .warning-banner {
background: rgba(180, 130, 30, 0.1);
border-color: rgba(180, 130, 30, 0.3);
color: #92600a;
}
[data-theme="dark"] {
color-scheme: dark;
}
@@ -0,0 +1,101 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type { PresetId, ThemeConfig, ThemePreset } from "./types.ts";
import { DEFAULT_THEME_CONFIG } from "./types.ts";
import { getPreset } from "./presets.ts";
import { applyTheme } from "./theme-engine.ts";
const STORAGE_KEY = "ua-theme";
interface ThemeContextValue {
config: ThemeConfig;
preset: ThemePreset;
setPreset: (presetId: PresetId) => void;
setAccent: (accentId: string) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function loadFromLocalStorage(): ThemeConfig | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") {
return parsed as ThemeConfig;
}
return null;
} catch {
return null;
}
}
function saveToLocalStorage(config: ThemeConfig): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// Storage full or unavailable — ignore
}
}
function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig {
return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG;
}
interface ThemeProviderProps {
metaTheme?: ThemeConfig | null;
children: ReactNode;
}
export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) {
const [config, setConfig] = useState<ThemeConfig>(() => resolveInitialTheme(metaTheme));
const initialized = useRef(false);
// Apply theme on mount and config changes
useEffect(() => {
applyTheme(config);
if (initialized.current) {
saveToLocalStorage(config);
}
initialized.current = true;
}, [config]);
// Update if metaTheme arrives later (async fetch) and no localStorage preference exists
useEffect(() => {
if (metaTheme && !loadFromLocalStorage()) {
setConfig(metaTheme);
}
}, [metaTheme]);
const setPreset = useCallback((presetId: PresetId) => {
setConfig((_prev) => {
const newPreset = getPreset(presetId);
return { presetId, accentId: newPreset.defaultAccentId };
});
}, []);
const setAccent = useCallback((accentId: string) => {
setConfig((prev) => ({ ...prev, accentId }));
}, []);
const preset = getPreset(config.presetId);
return (
<ThemeContext.Provider value={{ config, preset, setPreset, setAccent }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
@@ -0,0 +1,5 @@
export { ThemeProvider, useTheme } from "./ThemeContext.tsx";
export { PRESETS, getPreset, getAccent } from "./presets.ts";
export { applyTheme } from "./theme-engine.ts";
export type { PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts";
export { DEFAULT_THEME_CONFIG } from "./types.ts";
@@ -0,0 +1,143 @@
import type { AccentSwatch, ThemePreset } from "./types.ts";
const DARK_ACCENT_SWATCHES: AccentSwatch[] = [
{ id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" },
{ id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" },
{ id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" },
{ id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" },
{ id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" },
{ id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" },
{ id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" },
{ id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" },
];
const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [
{ id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" },
{ id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" },
{ id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" },
{ id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" },
{ id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" },
{ id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" },
{ id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" },
{ id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" },
];
export const PRESETS: ThemePreset[] = [
{
id: "dark-gold",
name: "Dark Gold",
isDark: true,
defaultAccentId: "gold",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a0a0a",
surface: "#111111",
elevated: "#1a1a1a",
panel: "#141414",
"text-primary": "#f5f0eb",
"text-secondary": "#a39787",
"text-muted": "#6b5f53",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
},
},
{
id: "dark-ocean",
name: "Dark Ocean",
isDark: true,
defaultAccentId: "ocean",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a0e14",
surface: "#111820",
elevated: "#1a222c",
panel: "#141c24",
"text-primary": "#e8edf2",
"text-secondary": "#87939f",
"text-muted": "#536b7a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
},
},
{
id: "dark-forest",
name: "Dark Forest",
isDark: true,
defaultAccentId: "emerald",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#0a100a",
surface: "#111811",
elevated: "#1a241a",
panel: "#141c14",
"text-primary": "#ebf0eb",
"text-secondary": "#87a38f",
"text-muted": "#536b5a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
},
},
{
id: "dark-rose",
name: "Dark Rose",
isDark: true,
defaultAccentId: "rose",
accentSwatches: DARK_ACCENT_SWATCHES,
colors: {
root: "#100a0a",
surface: "#181111",
elevated: "#221a1a",
panel: "#1c1414",
"text-primary": "#f2e8ea",
"text-secondary": "#9f8790",
"text-muted": "#6b535a",
"node-file": "#4a7c9b",
"node-function": "#5a9e6f",
"node-class": "#8b6fb0",
"node-module": "#c9a06c",
"node-concept": "#b07a8a",
},
},
{
id: "light-minimal",
name: "Light Minimal",
isDark: false,
defaultAccentId: "indigo",
accentSwatches: LIGHT_ACCENT_SWATCHES,
colors: {
root: "#f5f3f0",
surface: "#eae7e3",
elevated: "#ffffff",
panel: "#f0ede9",
"text-primary": "#1a1a1a",
"text-secondary": "#6b6b6b",
"text-muted": "#a0a0a0",
"node-file": "#3a6a87",
"node-function": "#488a5b",
"node-class": "#755d99",
"node-module": "#a88a56",
"node-concept": "#966674",
},
},
];
export function getPreset(id: string): ThemePreset {
return PRESETS.find((p) => p.id === id) ?? PRESETS[0];
}
export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch {
return (
preset.accentSwatches.find((s) => s.id === accentId) ??
preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ??
preset.accentSwatches[0]
);
}
@@ -0,0 +1,56 @@
import type { ThemeConfig } from "./types.ts";
import { getAccent, getPreset } from "./presets.ts";
export function hexToRgb(hex: string): string {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
}
function deriveFromAccent(accentHex: string, isDark: boolean): Record<string, string> {
const rgb = hexToRgb(accentHex);
return {
"color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`,
"color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`,
"glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)",
"glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)",
"glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`,
"glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`,
"scrollbar-thumb": `rgba(${rgb}, 0.2)`,
"scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`,
"glow-accent": `rgba(${rgb}, 0.15)`,
"glow-accent-strong": `rgba(${rgb}, 0.4)`,
"glow-accent-pulse": `rgba(${rgb}, 0.6)`,
"color-edge": `rgba(${rgb}, 0.3)`,
"color-edge-dim": `rgba(${rgb}, 0.08)`,
"color-edge-dot": `rgba(${rgb}, 0.15)`,
"color-accent-overlay-bg": `rgba(${rgb}, 0.05)`,
"color-accent-overlay-border": `rgba(${rgb}, 0.25)`,
"kbd-bg": `rgba(${rgb}, 0.1)`,
};
}
export function applyTheme(config: ThemeConfig): void {
const preset = getPreset(config.presetId);
const accent = getAccent(preset, config.accentId);
const style = document.documentElement.style;
// 1. Apply base preset colors
for (const [key, value] of Object.entries(preset.colors)) {
style.setProperty(`--color-${key}`, value);
}
// 2. Apply accent colors from swatch
style.setProperty("--color-accent", accent.accent);
style.setProperty("--color-accent-dim", accent.accentDim);
style.setProperty("--color-accent-bright", accent.accentBright);
// 3. Apply derived values
const derived = deriveFromAccent(accent.accent, preset.isDark);
for (const [key, value] of Object.entries(derived)) {
style.setProperty(`--${key}`, value);
}
// 4. Set data-theme for CSS-only selectors
document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light");
}
@@ -0,0 +1,33 @@
export type PresetId =
| "dark-gold"
| "dark-ocean"
| "dark-forest"
| "dark-rose"
| "light-minimal";
export interface AccentSwatch {
id: string;
name: string;
accent: string;
accentDim: string;
accentBright: string;
}
export interface ThemePreset {
id: PresetId;
name: string;
isDark: boolean;
colors: Record<string, string>;
accentSwatches: AccentSwatch[];
defaultAccentId: string;
}
export interface ThemeConfig {
presetId: PresetId;
accentId: string;
}
export const DEFAULT_THEME_CONFIG: ThemeConfig = {
presetId: "dark-gold",
accentId: "gold",
};