diff --git a/src/App.tsx b/src/App.tsx
index 2721335..b70b6fe 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -11,7 +11,6 @@ import { SearchPage } from "./components/SearchPage";
import { DiscoverPage } from "./components/DiscoverPage";
import { Player } from "./components/Player";
import { SettingsScreen } from "./components/SettingsScreen";
-import { ThemeProvider } from "./context/ThemeContext";
import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app";
@@ -185,16 +184,14 @@ export function App() {
};
return (
-
-
- }
- footer={}
- >
- {renderContent()}
-
-
+
+ }
+ footer={}
+ >
+ {renderContent()}
+
);
}
diff --git a/src/components/LayerIndicator.tsx b/src/components/LayerIndicator.tsx
index a286041..507c871 100644
--- a/src/components/LayerIndicator.tsx
+++ b/src/components/LayerIndicator.tsx
@@ -1,13 +1,13 @@
-import { useRenderer } from "@opentui/solid"
+import { useTheme } from "../context/ThemeContext"
export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
- const renderer = useRenderer()
+ const { theme } = useTheme()
const getLayerIndicator = () => {
const indicators = []
for (let i = 0; i < 4; i++) {
const isActive = i <= layerDepth
- const color = isActive ? "var(--color-accent)" : "var(--color-muted)"
+ const color = isActive ? theme.accent : theme.textMuted
const size = isActive ? "●" : "○"
indicators.push(
@@ -20,9 +20,9 @@ export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
return (
- Depth:
+ Depth:
{getLayerIndicator()}
-
+
{layerDepth}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 8fcc0f0..3265702 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,41 +1,34 @@
import type { JSX } from "solid-js"
-import type { ThemeColors } from "../types/settings"
-import type { ColorValue } from "../types/theme-schema"
-import { resolveColorReference } from "../utils/theme-css"
+import type { RGBA } from "@opentui/core"
+import { useTheme } from "../context/ThemeContext"
import { LayerIndicator } from "./LayerIndicator"
type LayerConfig = {
depth: number
- background: string
+ background: RGBA
}
type LayoutProps = {
header?: JSX.Element
footer?: JSX.Element
children?: JSX.Element
- theme?: ThemeColors
layerDepth?: number
}
export function Layout(props: LayoutProps) {
- const theme = props.theme
- const toColor = (value?: ColorValue) => (value ? resolveColorReference(value) : undefined)
+ const { theme } = useTheme()
// Get layer configuration based on depth
const getLayerConfig = (depth: number): LayerConfig => {
- if (!theme?.layerBackgrounds) {
- return { depth: 0, background: "transparent" }
- }
-
const backgrounds = theme.layerBackgrounds
const depthMap: Record = {
- 0: { depth: 0, background: resolveColorReference(backgrounds.layer0) },
- 1: { depth: 1, background: resolveColorReference(backgrounds.layer1) },
- 2: { depth: 2, background: resolveColorReference(backgrounds.layer2) },
- 3: { depth: 3, background: resolveColorReference(backgrounds.layer3) },
+ 0: { depth: 0, background: backgrounds?.layer0 ?? theme.background },
+ 1: { depth: 1, background: backgrounds?.layer1 ?? theme.backgroundPanel },
+ 2: { depth: 2, background: backgrounds?.layer2 ?? theme.backgroundElement },
+ 3: { depth: 3, background: backgrounds?.layer3 ?? theme.backgroundMenu },
}
- return depthMap[depth] || { depth: 0, background: "transparent" }
+ return depthMap[depth] || { depth: 0, background: theme.background }
}
// Get current layer background
@@ -46,14 +39,14 @@ export function Layout(props: LayoutProps) {
flexDirection="column"
width="100%"
height="100%"
- backgroundColor={toColor(theme?.background)}
+ backgroundColor={theme.background}
>
{/* Header */}
{props.header ? (
@@ -83,7 +76,7 @@ export function Layout(props: LayoutProps) {
@@ -99,7 +92,7 @@ export function Layout(props: LayoutProps) {
diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx
index e43c237..5628b0b 100644
--- a/src/components/LoginScreen.tsx
+++ b/src/components/LoginScreen.tsx
@@ -5,6 +5,7 @@
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
+import { useTheme } from "../context/ThemeContext"
import { AUTH_CONFIG } from "../config/auth"
interface LoginScreenProps {
@@ -17,6 +18,7 @@ type FocusField = "email" | "password" | "submit" | "code" | "oauth"
export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore()
+ const { theme } = useTheme()
const [email, setEmail] = createSignal("")
const [password, setPassword] = createSignal("")
const [focusField, setFocusField] = createSignal("email")
@@ -90,7 +92,7 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Email field */}
- Email:
+ Email:
{emailError() && (
- {emailError()}
+ {emailError()}
)}
{/* Password field */}
-
+
Password:
{passwordError() && (
- {passwordError()}
+ {passwordError()}
)}
@@ -127,9 +129,9 @@ export function LoginScreen(props: LoginScreenProps) {
-
+
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
@@ -137,21 +139,21 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Auth error message */}
{auth.error && (
- {auth.error.message}
+ {auth.error.message}
)}
{/* Alternative auth options */}
- Or authenticate with:
+ Or authenticate with:
-
+
[C] Sync Code
@@ -159,9 +161,9 @@ export function LoginScreen(props: LoginScreenProps) {
-
+
[O] OAuth Info
@@ -169,7 +171,7 @@ export function LoginScreen(props: LoginScreenProps) {
- Tab to navigate, Enter to select
+ Tab to navigate, Enter to select
)
}
diff --git a/src/components/PreferencesPanel.tsx b/src/components/PreferencesPanel.tsx
index 509a2ea..1a79bca 100644
--- a/src/components/PreferencesPanel.tsx
+++ b/src/components/PreferencesPanel.tsx
@@ -1,6 +1,7 @@
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useAppStore } from "../stores/app"
+import { useTheme } from "../context/ThemeContext"
import type { ThemeName } from "../types/settings"
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"
@@ -16,6 +17,7 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
export function PreferencesPanel() {
const appStore = useAppStore()
+ const { theme } = useTheme()
const [focusField, setFocusField] = createSignal("theme")
const settings = () => appStore.state().settings
@@ -76,55 +78,55 @@ export function PreferencesPanel() {
return (
- Preferences
+ Preferences
- Theme:
+ Theme:
- {THEME_LABELS.find((t) => t.value === settings().theme)?.label}
+ {THEME_LABELS.find((t) => t.value === settings().theme)?.label}
- [Left/Right]
+ [Left/Right]
- Font Size:
+ Font Size:
- {settings().fontSize}px
+ {settings().fontSize}px
- [Left/Right]
+ [Left/Right]
- Playback:
+ Playback:
- {settings().playbackSpeed}x
+ {settings().playbackSpeed}x
- [Left/Right]
+ [Left/Right]
- Show Explicit:
+ Show Explicit:
-
+
{preferences().showExplicit ? "On" : "Off"}
- [Space]
+ [Space]
- Auto Download:
+ Auto Download:
-
+
{preferences().autoDownload ? "On" : "Off"}
- [Space]
+ [Space]
- Tab to move focus, Left/Right to adjust
+ Tab to move focus, Left/Right to adjust
)
}
diff --git a/src/components/SettingsScreen.tsx b/src/components/SettingsScreen.tsx
index d340aa1..3a0e8c7 100644
--- a/src/components/SettingsScreen.tsx
+++ b/src/components/SettingsScreen.tsx
@@ -1,6 +1,7 @@
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { SourceManager } from "./SourceManager"
+import { useTheme } from "../context/ThemeContext"
import { PreferencesPanel } from "./PreferencesPanel"
import { SyncPanel } from "./SyncPanel"
@@ -21,6 +22,7 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [
]
export function SettingsScreen(props: SettingsScreenProps) {
+ const { theme } = useTheme()
const [activeSection, setActiveSection] = createSignal("sync")
useKeyboard((key) => {
@@ -50,7 +52,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
Settings
- [Tab] Switch section | 1-4 jump | Esc up
+ [Tab] Switch section | 1-4 jump | Esc up
@@ -58,10 +60,10 @@ export function SettingsScreen(props: SettingsScreenProps) {
setActiveSection(section.id)}
>
-
+
[{index + 1}] {section.label}
@@ -74,21 +76,21 @@ export function SettingsScreen(props: SettingsScreenProps) {
{activeSection() === "preferences" && }
{activeSection() === "account" && (
- Account
+ Account
- Status:
-
+ Status:
+
{props.accountLabel}
props.onOpenAccount?.()}>
- [A] Manage Account
+ [A] Manage Account
)}
- Enter to dive | Esc up
+ Enter to dive | Esc up
)
}
diff --git a/src/components/SourceManager.tsx b/src/components/SourceManager.tsx
index 903c75a..3da0177 100644
--- a/src/components/SourceManager.tsx
+++ b/src/components/SourceManager.tsx
@@ -5,6 +5,7 @@
import { createSignal, For } from "solid-js"
import { useFeedStore } from "../stores/feed"
+import { useTheme } from "../context/ThemeContext"
import { SourceType } from "../types/source"
import type { PodcastSource } from "../types/source"
@@ -17,6 +18,7 @@ type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore()
+ const { theme } = useTheme()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusArea, setFocusArea] = createSignal("list")
const [newSourceUrl, setNewSourceUrl] = createSignal("")
@@ -155,15 +157,15 @@ export function SourceManager(props: SourceManagerProps) {
Podcast Sources
- [Esc] Close
+ [Esc] Close
- Manage where to search for podcasts
+ Manage where to search for podcasts
{/* Source list */}
- Sources:
+ Sources:
{(source, index) => (
@@ -173,7 +175,7 @@ export function SourceManager(props: SourceManagerProps) {
padding={0}
backgroundColor={
focusArea() === "list" && index() === selectedIndex()
- ? "var(--color-primary)"
+ ? theme.primary
: undefined
}
onMouseDown={() => {
@@ -184,21 +186,21 @@ export function SourceManager(props: SourceManagerProps) {
>
{focusArea() === "list" && index() === selectedIndex()
? ">"
: " "}
-
+
{source.enabled ? "[x]" : "[ ]"}
- {getSourceIcon(source)}
+ {getSourceIcon(source)}
@@ -208,54 +210,54 @@ export function SourceManager(props: SourceManagerProps) {
)}
- Space/Enter to toggle, d to delete, a to add
+ Space/Enter to toggle, d to delete, a to add
{/* API settings */}
-
+
{isApiSource() ? "API Settings" : "API Settings (select an API source)"}
-
+
Country: {sourceCountry()}
-
+
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
-
+
Explicit: {sourceExplicit() ? "Yes" : "No"}
- Enter/Space to toggle focused setting
+ Enter/Space to toggle focused setting
{/* Add new source form */}
-
+
Add New Source:
- Name:
+ Name:
- URL:
+ URL:
{
@@ -285,16 +287,16 @@ export function SourceManager(props: SourceManagerProps) {
width={15}
onMouseDown={handleAddSource}
>
- [+] Add Source
+ [+] Add Source
{/* Error message */}
{error() && (
- {error()}
+ {error()}
)}
- Tab to switch sections, Esc to close
+ Tab to switch sections, Esc to close
)
}
diff --git a/src/components/Tab.tsx b/src/components/Tab.tsx
index 4053333..9e078b6 100644
--- a/src/components/Tab.tsx
+++ b/src/components/Tab.tsx
@@ -1,3 +1,5 @@
+import { useTheme } from "../context/ThemeContext"
+
export type TabId = "discover" | "feeds" | "search" | "player" | "settings"
export type TabDefinition = {
@@ -20,11 +22,12 @@ type TabProps = {
}
export function Tab(props: TabProps) {
+ const { theme } = useTheme()
return (
props.onSelect(props.tab.id)}
- style={{ padding: 1, backgroundColor: props.active ? "var(--color-primary)" : "transparent" }}
+ style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }}
>
{props.active ? "[" : " "}
diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx
index 7c31111..5da095e 100644
--- a/src/context/ThemeContext.tsx
+++ b/src/context/ThemeContext.tsx
@@ -1,4 +1,4 @@
-import { createContext, createEffect, createMemo, createSignal, useContext } from "solid-js"
+import { createContext, createEffect, createMemo, createSignal, Show, useContext } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import type { ThemeName } from "../types/settings"
@@ -7,13 +7,76 @@ import { useAppStore } from "../stores/app"
import { THEME_JSON } from "../constants/themes"
import { resolveTheme } from "../utils/theme-resolver"
import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter"
-import { generateSystemTheme } from "../utils/system-theme"
-import { getCustomThemes } from "../utils/custom-themes"
-import { setThemeAttribute } from "../utils/theme"
-import type { RGBA } from "@opentui/core"
+import { resolveTerminalTheme, loadThemes } from "../utils/theme"
+import type { RGBA, TerminalColors } from "@opentui/core"
+
+type ThemeResolved = {
+ primary: RGBA
+ secondary: RGBA
+ accent: RGBA
+ error: RGBA
+ warning: RGBA
+ success: RGBA
+ info: RGBA
+ text: RGBA
+ textMuted: RGBA
+ selectedListItemText: RGBA
+ background: RGBA
+ backgroundPanel: RGBA
+ backgroundElement: RGBA
+ backgroundMenu: RGBA
+ border: RGBA
+ borderActive: RGBA
+ borderSubtle: RGBA
+ diffAdded: RGBA
+ diffRemoved: RGBA
+ diffContext: RGBA
+ diffHunkHeader: RGBA
+ diffHighlightAdded: RGBA
+ diffHighlightRemoved: RGBA
+ diffAddedBg: RGBA
+ diffRemovedBg: RGBA
+ diffContextBg: RGBA
+ diffLineNumber: RGBA
+ diffAddedLineNumberBg: RGBA
+ diffRemovedLineNumberBg: RGBA
+ markdownText: RGBA
+ markdownHeading: RGBA
+ markdownLink: RGBA
+ markdownLinkText: RGBA
+ markdownCode: RGBA
+ markdownBlockQuote: RGBA
+ markdownEmph: RGBA
+ markdownStrong: RGBA
+ markdownHorizontalRule: RGBA
+ markdownListItem: RGBA
+ markdownListEnumeration: RGBA
+ markdownImage: RGBA
+ markdownImageText: RGBA
+ markdownCodeBlock: RGBA
+ syntaxComment: RGBA
+ syntaxKeyword: RGBA
+ syntaxFunction: RGBA
+ syntaxVariable: RGBA
+ syntaxString: RGBA
+ syntaxNumber: RGBA
+ syntaxType: RGBA
+ syntaxOperator: RGBA
+ syntaxPunctuation: RGBA
+ muted?: RGBA
+ surface?: RGBA
+ layerBackgrounds?: {
+ layer0: RGBA
+ layer1: RGBA
+ layer2: RGBA
+ layer3: RGBA
+ }
+ _hasSelectedListItemText?: boolean
+ thinkingOpacity?: number
+}
type ThemeContextValue = {
- theme: Record
+ theme: ThemeResolved
selected: () => string
all: () => Record
syntax: () => unknown
@@ -31,13 +94,14 @@ export function ThemeProvider({ children }: { children: any }) {
const renderer = useRenderer()
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore({
- themes: { ...THEME_JSON } as Record,
+ themes: {} as Record,
mode: "dark" as "dark" | "light",
active: appStore.state().settings.theme as ThemeName,
+ system: undefined as undefined | TerminalColors,
})
const init = () => {
- getCustomThemes()
+ loadThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -52,26 +116,18 @@ export function ThemeProvider({ children }: { children: any }) {
createEffect(() => {
setStore("active", appStore.state().settings.theme)
- setThemeAttribute(appStore.state().settings.theme)
})
createEffect(() => {
- if (store.active !== "system") return
renderer
.getPalette({ size: 16 })
- .then((colors) => {
- setStore(
- produce((draft) => {
- draft.themes.system = generateSystemTheme(colors, store.mode)
- })
- )
- })
+ .then((colors) => setStore("system", colors))
.catch(() => {})
})
const values = createMemo(() => {
- const theme = store.themes[store.active] ?? store.themes.opencode
- return resolveTheme(theme, store.mode)
+ const themes = Object.keys(store.themes).length ? store.themes : THEME_JSON
+ return resolveTerminalTheme(themes, store.active, store.mode, store.system)
})
const syntax = createMemo(() => generateSyntax(values() as unknown as Record))
@@ -80,11 +136,11 @@ export function ThemeProvider({ children }: { children: any }) {
)
const context: ThemeContextValue = {
- theme: new Proxy(values(), {
- get(_target, prop) {
- return values()[prop as keyof typeof values]
- },
- }),
+ theme: new Proxy(values(), {
+ get(_target, prop) {
+ return values()[prop as keyof typeof values]
+ },
+ }) as ThemeResolved,
selected: () => store.active,
all: () => store.themes,
syntax,
@@ -95,7 +151,11 @@ export function ThemeProvider({ children }: { children: any }) {
ready,
}
- return {children}
+ return (
+
+ {children}
+
+ )
}
export function useTheme() {
diff --git a/src/index.tsx b/src/index.tsx
index 9d800cd..696c364 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,5 +1,10 @@
import { render } from "@opentui/solid"
import { App } from "./App"
+import { ThemeProvider } from "./context/ThemeContext"
import "./styles/theme.css"
-render(() => )
+render(() => (
+
+
+
+))
diff --git a/src/utils/custom-themes.ts b/src/utils/custom-themes.ts
index ee67acd..e5b62a5 100644
--- a/src/utils/custom-themes.ts
+++ b/src/utils/custom-themes.ts
@@ -1,23 +1,30 @@
import path from "path"
+import { mkdir } from "fs/promises"
import type { ThemeJson } from "../types/theme-schema"
+import { THEME_JSON } from "../constants/themes"
import { validateTheme } from "./theme-loader"
export async function getCustomThemes() {
- const dirs = [
- path.join(process.env.HOME ?? "", ".config/podtui/themes"),
- path.resolve(process.cwd(), ".podtui/themes"),
- path.resolve(process.cwd(), "themes"),
- ]
+ const home = process.env.HOME ?? ""
+ if (!home) return {}
+
+ const dir = path.join(home, ".config/podtui/themes")
+ await mkdir(dir, { recursive: true })
+
+ for (const [name, theme] of Object.entries(THEME_JSON)) {
+ const file = path.join(dir, `${name}.json`)
+ const exists = await Bun.file(file).exists()
+ if (exists) continue
+ await Bun.write(file, JSON.stringify(theme, null, 2))
+ }
const result: Record = {}
- for (const dir of dirs) {
- const glob = new Bun.Glob("*.json")
- for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
- const name = path.basename(item, ".json")
- const json = (await Bun.file(item).json()) as ThemeJson
- validateTheme(json, item)
- result[name] = json
- }
+ const glob = new Bun.Glob("*.json")
+ for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
+ const name = path.basename(item, ".json")
+ const json = (await Bun.file(item).json()) as ThemeJson
+ validateTheme(json, item)
+ result[name] = json
}
return result
}
diff --git a/src/utils/theme-resolver.ts b/src/utils/theme-resolver.ts
index 7389120..8c565b3 100644
--- a/src/utils/theme-resolver.ts
+++ b/src/utils/theme-resolver.ts
@@ -5,6 +5,9 @@ import { ansiToRgba } from "./ansi-to-rgba"
export type ThemeMode = "dark" | "light"
export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
+ if (!theme || !theme.theme) {
+ throw new Error("Invalid theme: missing theme object")
+ }
const defs = theme.defs ?? {}
function resolveColor(value: ColorValue): RGBA {
@@ -38,8 +41,21 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6
+ const background = resolved.background
+ const backgroundPanel = resolved.backgroundPanel ?? background
+ const backgroundElement = resolved.backgroundElement ?? backgroundPanel
+ const backgroundMenu = resolved.backgroundMenu ?? backgroundElement
+
return {
...resolved,
+ muted: resolved.textMuted ?? resolved.muted,
+ surface: resolved.backgroundPanel ?? resolved.surface,
+ layerBackgrounds: {
+ layer0: background,
+ layer1: backgroundPanel,
+ layer2: backgroundElement,
+ layer3: backgroundMenu,
+ },
_hasSelectedListItemText: hasSelected,
thinkingOpacity,
}
diff --git a/src/utils/theme.ts b/src/utils/theme.ts
index bc34b2e..0398986 100644
--- a/src/utils/theme.ts
+++ b/src/utils/theme.ts
@@ -3,9 +3,13 @@
* Handles dynamic theme switching by updating CSS custom properties
*/
-import { RGBA } from "@opentui/core"
+import { RGBA, type TerminalColors } from "@opentui/core"
import type { ThemeColors } from "../types/settings"
-import type { ColorValue } from "../types/theme-schema"
+import type { ColorValue, ThemeJson } from "../types/theme-schema"
+import { THEME_JSON } from "../constants/themes"
+import { getCustomThemes } from "./custom-themes"
+import { resolveTheme as resolveThemeJson } from "./theme-resolver"
+import { generateSystemTheme } from "./system-theme"
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
@@ -60,3 +64,32 @@ export function setThemeAttribute(themeName: string) {
const root = document.documentElement
root.setAttribute("data-theme", themeName)
}
+
+export async function loadThemes() {
+ return await getCustomThemes()
+}
+
+export async function loadTheme(name: string) {
+ const themes = await loadThemes()
+ return themes[name]
+}
+
+export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
+ return resolveThemeJson(theme, mode)
+}
+
+export function resolveTerminalTheme(
+ themes: Record,
+ name: string,
+ mode: "dark" | "light",
+ system?: TerminalColors
+) {
+ if (name === "system" && system) {
+ return resolveThemeJson(generateSystemTheme(system, mode), mode)
+ }
+ const theme = themes[name] ?? themes.opencode
+ if (!theme) {
+ return resolveThemeJson(THEME_JSON.opencode, mode)
+ }
+ return resolveThemeJson(theme, mode)
+}