mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #45 from Lum1104/feat/theme-system
feat(dashboard): add curated theme preset system with accent customization
This commit is contained in:
@@ -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 />
|
||||
|
||||
+1
-1
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user