- {THEMES.map((t, i) => {
- const selected = theme === t.key;
- return (
+
+ {/* Mode Toggle */}
+
+
+ Theme Mode
+
+
- );
- })}
+
+
+
+
+ {/* Theme Color Selector */}
+
+
+ Color Theme
+
+
+ {currentThemes.map((t, i) => {
+ const selected = theme === t.key;
+ return (
+
+ );
+ })}
+
+
);
}
diff --git a/src/features/shared/theme/index.ts b/src/features/shared/theme/index.ts
new file mode 100644
index 0000000..8d4ab13
--- /dev/null
+++ b/src/features/shared/theme/index.ts
@@ -0,0 +1,12 @@
+export { ThemeProvider, useTheme } from "./theme-context";
+export type { ThemeKey, ThemeMode, LightThemeKey, DarkThemeKey, ThemeInfo } from "./theme-types";
+export {
+ DARK_THEMES,
+ LIGHT_THEMES,
+ ALL_THEMES,
+ getThemeMode,
+ isLightTheme,
+ isDarkTheme,
+ getThemesByMode,
+ getDefaultTheme
+} from "./theme-types";
\ No newline at end of file
diff --git a/src/features/shared/theme/theme-context.tsx b/src/features/shared/theme/theme-context.tsx
new file mode 100644
index 0000000..488cfea
--- /dev/null
+++ b/src/features/shared/theme/theme-context.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
+import type { ThemeKey, ThemeMode } from "./theme-types";
+import { getThemeMode, getDefaultTheme, ALL_THEMES } from "./theme-types";
+
+interface ThemeContextType {
+ theme: ThemeKey;
+ mode: ThemeMode;
+ setTheme: (theme: ThemeKey) => void;
+ setMode: (mode: ThemeMode) => void;
+ isLight: boolean;
+ isDark: boolean;
+}
+
+const ThemeContext = createContext
(undefined);
+
+interface ThemeProviderProps {
+ children: ReactNode;
+ defaultTheme?: ThemeKey;
+}
+
+export function ThemeProvider({ children, defaultTheme = "theme-modern-teal" }: ThemeProviderProps) {
+ const [theme, setThemeState] = useState(defaultTheme);
+ const mode = getThemeMode(theme);
+
+ const applyTheme = (newTheme: ThemeKey) => {
+ if (typeof document !== 'undefined') {
+ const root = document.documentElement;
+ // Remove all theme classes
+ ALL_THEMES.forEach(t => root.classList.remove(t.key));
+ // Add new theme class
+ root.classList.add(newTheme);
+ }
+
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem('rtap.theme', newTheme);
+ }
+
+ setThemeState(newTheme);
+ };
+
+ const setTheme = (newTheme: ThemeKey) => {
+ applyTheme(newTheme);
+ };
+
+ const setMode = (newMode: ThemeMode) => {
+ const currentMode = getThemeMode(theme);
+ if (currentMode !== newMode) {
+ const newTheme = getDefaultTheme(newMode);
+ applyTheme(newTheme);
+ }
+ };
+
+ // Load stored theme on mount
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ const stored = localStorage.getItem('rtap.theme') as ThemeKey | null;
+ const isValidTheme = stored && ALL_THEMES.some(t => t.key === stored);
+ const initial = isValidTheme ? stored : defaultTheme;
+ applyTheme(initial);
+ }
+ }, [defaultTheme]);
+
+ const value: ThemeContextType = {
+ theme,
+ mode,
+ setTheme,
+ setMode,
+ isLight: mode === "light",
+ isDark: mode === "dark",
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+}
\ No newline at end of file
diff --git a/src/features/shared/theme/theme-types.ts b/src/features/shared/theme/theme-types.ts
new file mode 100644
index 0000000..2e9b720
--- /dev/null
+++ b/src/features/shared/theme/theme-types.ts
@@ -0,0 +1,44 @@
+export type DarkThemeKey = "theme-modern-teal" | "theme-modern-blue" | "theme-modern-ember";
+export type LightThemeKey = "theme-light-neutral" | "theme-light-blue" | "theme-light-warm";
+export type ThemeKey = DarkThemeKey | LightThemeKey;
+export type ThemeMode = "dark" | "light";
+
+export interface ThemeInfo {
+ key: ThemeKey;
+ label: string;
+ mode: ThemeMode;
+}
+
+export const DARK_THEMES: ThemeInfo[] = [
+ { key: "theme-modern-teal", label: "Teal", mode: "dark" },
+ { key: "theme-modern-blue", label: "Blue", mode: "dark" },
+ { key: "theme-modern-ember", label: "Ember", mode: "dark" },
+];
+
+export const LIGHT_THEMES: ThemeInfo[] = [
+ { key: "theme-light-neutral", label: "Neutral", mode: "light" },
+ { key: "theme-light-blue", label: "Blue", mode: "light" },
+ { key: "theme-light-warm", label: "Warm", mode: "light" },
+];
+
+export const ALL_THEMES: ThemeInfo[] = [...DARK_THEMES, ...LIGHT_THEMES];
+
+export function getThemeMode(theme: ThemeKey): ThemeMode {
+ return theme.startsWith("theme-light-") ? "light" : "dark";
+}
+
+export function isLightTheme(theme: ThemeKey): theme is LightThemeKey {
+ return theme.startsWith("theme-light-");
+}
+
+export function isDarkTheme(theme: ThemeKey): theme is DarkThemeKey {
+ return theme.startsWith("theme-modern-");
+}
+
+export function getThemesByMode(mode: ThemeMode): ThemeInfo[] {
+ return mode === "light" ? LIGHT_THEMES : DARK_THEMES;
+}
+
+export function getDefaultTheme(mode: ThemeMode): ThemeKey {
+ return mode === "light" ? "theme-light-neutral" : "theme-modern-teal";
+}
\ No newline at end of file
diff --git a/src/lib/exportImage.ts b/src/lib/exportImage.ts
index 7c4ff49..56bb99e 100644
--- a/src/lib/exportImage.ts
+++ b/src/lib/exportImage.ts
@@ -1,6 +1,8 @@
"use client";
import { toPng } from "html-to-image";
+import type { ThemeKey } from "@features/shared/theme";
+import { isLightTheme, getDefaultTheme } from "@features/shared/theme";
import { shouldExportNode } from "./shouldExportNode";
@@ -11,6 +13,12 @@ type StyleOverride = {
priority: string;
};
+interface ExportOptions {
+ forceTheme?: ThemeKey;
+ optimizeForPrint?: boolean;
+ backgroundColor?: string;
+}
+
function restoreStyleOverrides(overrides: StyleOverride[]) {
for (let i = overrides.length - 1; i >= 0; i -= 1) {
const override = overrides[i];
@@ -91,15 +99,76 @@ function applyExportDirectivesInPlace(root: HTMLElement) {
};
}
-export async function captureElementToPng(element: HTMLElement): Promise {
+function applyExportThemeOverride(root: HTMLElement, targetTheme?: ThemeKey, optimizeForPrint = false) {
+ const overrides: StyleOverride[] = [];
+ const htmlElement = document.documentElement;
+
+ if (targetTheme || optimizeForPrint) {
+ // Determine the theme to use for export
+ const exportTheme = optimizeForPrint ? getDefaultTheme("light") : targetTheme;
+
+ if (exportTheme) {
+ // Store current theme classes
+ const currentClasses = Array.from(htmlElement.classList);
+ const themeClasses = currentClasses.filter(cls =>
+ cls.startsWith("theme-modern-") || cls.startsWith("theme-light-")
+ );
+
+ // Apply export theme
+ themeClasses.forEach(cls => htmlElement.classList.remove(cls));
+ htmlElement.classList.add(exportTheme);
+
+ // If using light theme for export, ensure proper contrast and visibility
+ if (isLightTheme(exportTheme) || optimizeForPrint) {
+ applyStyleOverride(root, "background-color", "#ffffff", "important", overrides);
+ applyStyleOverride(root, "color", "#0f172a", "important", overrides);
+
+ // Remove any glow effects that don't work well in light mode
+ const glowElements = root.querySelectorAll(".glow-accent, .glow-subtle, .glow-error");
+ glowElements.forEach(el => {
+ applyStyleOverride(el, "box-shadow", "none", "important", overrides);
+ });
+ }
+
+ return () => {
+ // Restore original theme classes
+ htmlElement.classList.remove(exportTheme);
+ themeClasses.forEach(cls => htmlElement.classList.add(cls));
+ restoreStyleOverrides(overrides);
+ };
+ }
+ }
+
+ return () => {
+ restoreStyleOverrides(overrides);
+ };
+}
+
+export async function captureElementToPng(
+ element: HTMLElement,
+ options: ExportOptions = {}
+): Promise {
+ const { forceTheme, optimizeForPrint = false, backgroundColor } = options;
+
const rootOverrides: StyleOverride[] = [];
const restore = applyExportDirectivesInPlace(element);
+ const restoreTheme = applyExportThemeOverride(element, forceTheme, optimizeForPrint);
try {
const width = element.scrollWidth || element.clientWidth;
const height = element.scrollHeight || element.clientHeight;
const computed = getComputedStyle(element);
- const { background, backgroundColor } = computed;
+
+ // Determine background color
+ let finalBackgroundColor = backgroundColor;
+ if (!finalBackgroundColor) {
+ if (optimizeForPrint || (forceTheme && isLightTheme(forceTheme))) {
+ finalBackgroundColor = "#ffffff";
+ } else {
+ const { background, backgroundColor: computedBg } = computed;
+ finalBackgroundColor = computedBg === "rgba(0, 0, 0, 0)" ? undefined : computedBg;
+ }
+ }
applyStyleOverride(element, "width", `${width}px`, "important", rootOverrides);
applyStyleOverride(element, "height", `${height}px`, "important", rootOverrides);
@@ -109,12 +178,13 @@ export async function captureElementToPng(element: HTMLElement): Promise
skipFonts: true,
width,
height,
- backgroundColor: backgroundColor === "rgba(0, 0, 0, 0)" ? undefined : backgroundColor,
- style: background ? { background } : undefined,
+ backgroundColor: finalBackgroundColor,
+ style: finalBackgroundColor ? { background: finalBackgroundColor } : undefined,
filter: shouldExportNode,
});
} finally {
restoreStyleOverrides(rootOverrides);
+ restoreTheme();
restore();
}
}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 7498049..a0aa473 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -135,6 +135,96 @@
--ring: var(--color-accent);
}
+/* Light theme - Neutral */
+:root.theme-light-neutral {
+ /* Accent - maintain brand consistency with teal */
+ --color-accent-rgb: 59, 201, 186;
+ --color-accent: rgb(var(--color-accent-rgb));
+ --color-accent-muted: rgba(var(--color-accent-rgb), 0.8);
+ --color-accent-dim: rgba(var(--color-accent-rgb), 0.85);
+
+ /* Surfaces - inverted hierarchy for light mode */
+ --surface-0: #ffffff;
+ --surface-1: #f8fafc;
+ --surface-2: #f1f5f9;
+ --glass-alpha: 0.02;
+
+ --color-surface: var(--surface-0);
+ --color-surface-elevated: var(--surface-1);
+ --color-border: #e2e8f0;
+ --color-border-light: #cbd5e1;
+
+ /* Text - inverted hierarchy for light backgrounds */
+ --text-primary: #0f172a;
+ --text-secondary: #475569;
+ --text-muted: #64748b;
+ --color-text-primary: var(--text-primary);
+ --color-text-secondary: var(--text-secondary);
+ --color-text-muted: var(--text-muted);
+
+ --ring: var(--color-accent);
+}
+
+/* Light theme - Blue */
+:root.theme-light-blue {
+ /* Accent - light mode blue */
+ --color-accent-rgb: 37, 99, 235; /* blue-600 for better contrast on light */
+ --color-accent: rgb(var(--color-accent-rgb));
+ --color-accent-muted: rgba(var(--color-accent-rgb), 0.8);
+ --color-accent-dim: rgba(var(--color-accent-rgb), 0.85);
+
+ /* Surfaces */
+ --surface-0: #ffffff;
+ --surface-1: #f1f5f9;
+ --surface-2: #e2e8f0;
+ --glass-alpha: 0.02;
+
+ --color-surface: var(--surface-0);
+ --color-surface-elevated: var(--surface-1);
+ --color-border: #cbd5e1;
+ --color-border-light: #94a3b8;
+
+ /* Text */
+ --text-primary: #0f172a;
+ --text-secondary: #334155;
+ --text-muted: #64748b;
+ --color-text-primary: var(--text-primary);
+ --color-text-secondary: var(--text-secondary);
+ --color-text-muted: var(--text-muted);
+
+ --ring: var(--color-accent);
+}
+
+/* Light theme - Warm */
+:root.theme-light-warm {
+ /* Accent - warm orange for light mode */
+ --color-accent-rgb: 234, 88, 12; /* orange-600 */
+ --color-accent: rgb(var(--color-accent-rgb));
+ --color-accent-muted: rgba(var(--color-accent-rgb), 0.8);
+ --color-accent-dim: rgba(var(--color-accent-rgb), 0.85);
+
+ /* Surfaces - warm neutral palette */
+ --surface-0: #fefefe;
+ --surface-1: #fafaf9;
+ --surface-2: #f5f5f4;
+ --glass-alpha: 0.02;
+
+ --color-surface: var(--surface-0);
+ --color-surface-elevated: var(--surface-1);
+ --color-border: #e7e5e4;
+ --color-border-light: #d6d3d1;
+
+ /* Text */
+ --text-primary: #1c1917;
+ --text-secondary: #44403c;
+ --text-muted: #78716c;
+ --color-text-primary: var(--text-primary);
+ --color-text-secondary: var(--text-secondary);
+ --color-text-muted: var(--text-muted);
+
+ --ring: var(--color-accent);
+}
+
/* Base styles */
* {
border-color: var(--color-border);
@@ -205,6 +295,20 @@ body { background: linear-gradient(135deg, var(--surface-0) 0%, var(--surface-1)
--status-info-bg: rgba(var(--color-accent-rgb, 90,177,255), 0.16);
}
+/* Light theme status tokens */
+:root.theme-light-neutral,
+:root.theme-light-blue,
+:root.theme-light-warm {
+ --status-success-fg: #059669; /* green-600 for better contrast */
+ --status-success-bg: rgba(5,150,105,0.1);
+ --status-warn-fg: #d97706; /* amber-600 */
+ --status-warn-bg: rgba(217,119,6,0.1);
+ --status-error-fg: #dc2626; /* red-600 */
+ --status-error-bg: rgba(220,38,38,0.1);
+ --status-info-fg: var(--color-accent);
+ --status-info-bg: rgba(var(--color-accent-rgb), 0.1);
+}
+
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
@@ -222,3 +326,32 @@ body { background: linear-gradient(135deg, var(--surface-0) 0%, var(--surface-1)
::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
}
+
+/* Print optimization - force light mode for printing */
+@media print {
+ :root {
+ --surface-0: #ffffff !important;
+ --surface-1: #f8fafc !important;
+ --surface-2: #f1f5f9 !important;
+ --color-surface: #ffffff !important;
+ --color-surface-elevated: #f8fafc !important;
+ --color-border: #e2e8f0 !important;
+ --color-border-light: #cbd5e1 !important;
+ --text-primary: #0f172a !important;
+ --text-secondary: #475569 !important;
+ --text-muted: #64748b !important;
+ --color-text-primary: #0f172a !important;
+ --color-text-secondary: #475569 !important;
+ --color-text-muted: #64748b !important;
+ }
+
+ body {
+ background: white !important;
+ }
+
+ .glow-accent,
+ .glow-subtle,
+ .glow-error {
+ box-shadow: none !important;
+ }
+}