still self referencing

This commit is contained in:
2026-02-05 01:43:10 -05:00
parent ea9ab4d3f9
commit 9fa52d71ca
13 changed files with 263 additions and 141 deletions

View File

@@ -11,7 +11,6 @@ import { SearchPage } from "./components/SearchPage";
import { DiscoverPage } from "./components/DiscoverPage"; import { DiscoverPage } from "./components/DiscoverPage";
import { Player } from "./components/Player"; import { Player } from "./components/Player";
import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsScreen } from "./components/SettingsScreen";
import { ThemeProvider } from "./context/ThemeContext";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed"; import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app"; import { useAppStore } from "./stores/app";
@@ -185,16 +184,14 @@ export function App() {
}; };
return ( return (
<ThemeProvider> <Layout
<Layout layerDepth={layerDepth()}
layerDepth={layerDepth()} header={
header={ <TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} /> }
} footer={<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />}
footer={<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />} >
> <box style={{ padding: 1 }}>{renderContent()}</box>
<box style={{ padding: 1 }}>{renderContent()}</box> </Layout>
</Layout>
</ThemeProvider>
); );
} }

View File

@@ -1,13 +1,13 @@
import { useRenderer } from "@opentui/solid" import { useTheme } from "../context/ThemeContext"
export function LayerIndicator({ layerDepth }: { layerDepth: number }) { export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
const renderer = useRenderer() const { theme } = useTheme()
const getLayerIndicator = () => { const getLayerIndicator = () => {
const indicators = [] const indicators = []
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const isActive = i <= layerDepth const isActive = i <= layerDepth
const color = isActive ? "var(--color-accent)" : "var(--color-muted)" const color = isActive ? theme.accent : theme.textMuted
const size = isActive ? "●" : "○" const size = isActive ? "●" : "○"
indicators.push( indicators.push(
<text fg={color} marginRight={1}> <text fg={color} marginRight={1}>
@@ -20,9 +20,9 @@ export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
return ( return (
<box flexDirection="row" alignItems="center"> <box flexDirection="row" alignItems="center">
<text fg="var(--color-muted)" marginRight={1}>Depth:</text> <text fg={theme.textMuted} marginRight={1}>Depth:</text>
{getLayerIndicator()} {getLayerIndicator()}
<text fg="var(--color-muted)" marginLeft={1}> <text fg={theme.textMuted} marginLeft={1}>
{layerDepth} {layerDepth}
</text> </text>
</box> </box>

View File

@@ -1,41 +1,34 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import type { ThemeColors } from "../types/settings" import type { RGBA } from "@opentui/core"
import type { ColorValue } from "../types/theme-schema" import { useTheme } from "../context/ThemeContext"
import { resolveColorReference } from "../utils/theme-css"
import { LayerIndicator } from "./LayerIndicator" import { LayerIndicator } from "./LayerIndicator"
type LayerConfig = { type LayerConfig = {
depth: number depth: number
background: string background: RGBA
} }
type LayoutProps = { type LayoutProps = {
header?: JSX.Element header?: JSX.Element
footer?: JSX.Element footer?: JSX.Element
children?: JSX.Element children?: JSX.Element
theme?: ThemeColors
layerDepth?: number layerDepth?: number
} }
export function Layout(props: LayoutProps) { export function Layout(props: LayoutProps) {
const theme = props.theme const { theme } = useTheme()
const toColor = (value?: ColorValue) => (value ? resolveColorReference(value) : undefined)
// Get layer configuration based on depth // Get layer configuration based on depth
const getLayerConfig = (depth: number): LayerConfig => { const getLayerConfig = (depth: number): LayerConfig => {
if (!theme?.layerBackgrounds) {
return { depth: 0, background: "transparent" }
}
const backgrounds = theme.layerBackgrounds const backgrounds = theme.layerBackgrounds
const depthMap: Record<number, LayerConfig> = { const depthMap: Record<number, LayerConfig> = {
0: { depth: 0, background: resolveColorReference(backgrounds.layer0) }, 0: { depth: 0, background: backgrounds?.layer0 ?? theme.background },
1: { depth: 1, background: resolveColorReference(backgrounds.layer1) }, 1: { depth: 1, background: backgrounds?.layer1 ?? theme.backgroundPanel },
2: { depth: 2, background: resolveColorReference(backgrounds.layer2) }, 2: { depth: 2, background: backgrounds?.layer2 ?? theme.backgroundElement },
3: { depth: 3, background: resolveColorReference(backgrounds.layer3) }, 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 // Get current layer background
@@ -46,14 +39,14 @@ export function Layout(props: LayoutProps) {
flexDirection="column" flexDirection="column"
width="100%" width="100%"
height="100%" height="100%"
backgroundColor={toColor(theme?.background)} backgroundColor={theme.background}
> >
{/* Header */} {/* Header */}
{props.header ? ( {props.header ? (
<box <box
style={{ style={{
height: 4, height: 4,
backgroundColor: toColor(theme?.surface), backgroundColor: theme.surface ?? theme.backgroundPanel,
}} }}
> >
<box style={{ padding: 1 }}> <box style={{ padding: 1 }}>
@@ -83,7 +76,7 @@ export function Layout(props: LayoutProps) {
<box <box
style={{ style={{
height: 2, height: 2,
backgroundColor: toColor(theme?.surface), backgroundColor: theme.surface ?? theme.backgroundPanel,
}} }}
> >
<box style={{ padding: 1 }}> <box style={{ padding: 1 }}>
@@ -99,7 +92,7 @@ export function Layout(props: LayoutProps) {
<box <box
style={{ style={{
height: 1, height: 1,
backgroundColor: toColor(theme?.surface), backgroundColor: theme.surface ?? theme.backgroundPanel,
}} }}
> >
<box style={{ padding: 1 }}> <box style={{ padding: 1 }}>

View File

@@ -5,6 +5,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth" import { useAuthStore } from "../stores/auth"
import { useTheme } from "../context/ThemeContext"
import { AUTH_CONFIG } from "../config/auth" import { AUTH_CONFIG } from "../config/auth"
interface LoginScreenProps { interface LoginScreenProps {
@@ -17,6 +18,7 @@ type FocusField = "email" | "password" | "submit" | "code" | "oauth"
export function LoginScreen(props: LoginScreenProps) { export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore() const auth = useAuthStore()
const { theme } = useTheme()
const [email, setEmail] = createSignal("") const [email, setEmail] = createSignal("")
const [password, setPassword] = createSignal("") const [password, setPassword] = createSignal("")
const [focusField, setFocusField] = createSignal<FocusField>("email") const [focusField, setFocusField] = createSignal<FocusField>("email")
@@ -90,7 +92,7 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Email field */} {/* Email field */}
<box flexDirection="column" gap={0}> <box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? "var(--color-primary)" : undefined}>Email:</text> <text fg={focusField() === "email" ? theme.primary : undefined}>Email:</text>
<input <input
value={email()} value={email()}
onInput={setEmail} onInput={setEmail}
@@ -99,13 +101,13 @@ export function LoginScreen(props: LoginScreenProps) {
width={30} width={30}
/> />
{emailError() && ( {emailError() && (
<text fg="var(--color-error)">{emailError()}</text> <text fg={theme.error}>{emailError()}</text>
)} )}
</box> </box>
{/* Password field */} {/* Password field */}
<box flexDirection="column" gap={0}> <box flexDirection="column" gap={0}>
<text fg={focusField() === "password" ? "var(--color-primary)" : undefined}> <text fg={focusField() === "password" ? theme.primary : undefined}>
Password: Password:
</text> </text>
<input <input
@@ -116,7 +118,7 @@ export function LoginScreen(props: LoginScreenProps) {
width={30} width={30}
/> />
{passwordError() && ( {passwordError() && (
<text fg="var(--color-error)">{passwordError()}</text> <text fg={theme.error}>{passwordError()}</text>
)} )}
</box> </box>
@@ -127,9 +129,9 @@ export function LoginScreen(props: LoginScreenProps) {
<box <box
border border
padding={1} padding={1}
backgroundColor={focusField() === "submit" ? "var(--color-primary)" : undefined} backgroundColor={focusField() === "submit" ? theme.primary : undefined}
> >
<text fg={focusField() === "submit" ? "var(--color-text)" : undefined}> <text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"} {auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</text> </text>
</box> </box>
@@ -137,21 +139,21 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Auth error message */} {/* Auth error message */}
{auth.error && ( {auth.error && (
<text fg="var(--color-error)">{auth.error.message}</text> <text fg={theme.error}>{auth.error.message}</text>
)} )}
<box height={1} /> <box height={1} />
{/* Alternative auth options */} {/* Alternative auth options */}
<text fg="var(--color-muted)">Or authenticate with:</text> <text fg={theme.textMuted}>Or authenticate with:</text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<box <box
border border
padding={1} padding={1}
backgroundColor={focusField() === "code" ? "var(--color-primary)" : undefined} backgroundColor={focusField() === "code" ? theme.primary : undefined}
> >
<text fg={focusField() === "code" ? "var(--color-accent)" : "var(--color-muted)"}> <text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
[C] Sync Code [C] Sync Code
</text> </text>
</box> </box>
@@ -159,9 +161,9 @@ export function LoginScreen(props: LoginScreenProps) {
<box <box
border border
padding={1} padding={1}
backgroundColor={focusField() === "oauth" ? "var(--color-primary)" : undefined} backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
> >
<text fg={focusField() === "oauth" ? "var(--color-accent)" : "var(--color-muted)"}> <text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
[O] OAuth Info [O] OAuth Info
</text> </text>
</box> </box>
@@ -169,7 +171,7 @@ export function LoginScreen(props: LoginScreenProps) {
<box height={1} /> <box height={1} />
<text fg="var(--color-muted)">Tab to navigate, Enter to select</text> <text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box> </box>
) )
} }

View File

@@ -1,6 +1,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { useAppStore } from "../stores/app" import { useAppStore } from "../stores/app"
import { useTheme } from "../context/ThemeContext"
import type { ThemeName } from "../types/settings" import type { ThemeName } from "../types/settings"
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto" type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"
@@ -16,6 +17,7 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
export function PreferencesPanel() { export function PreferencesPanel() {
const appStore = useAppStore() const appStore = useAppStore()
const { theme } = useTheme()
const [focusField, setFocusField] = createSignal<FocusField>("theme") const [focusField, setFocusField] = createSignal<FocusField>("theme")
const settings = () => appStore.state().settings const settings = () => appStore.state().settings
@@ -76,55 +78,55 @@ export function PreferencesPanel() {
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg="var(--color-muted)">Preferences</text> <text fg={theme.textMuted}>Preferences</text>
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "theme" ? "var(--color-primary)" : "var(--color-muted)"}>Theme:</text> <text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>Theme:</text>
<box border padding={0}> <box border padding={0}>
<text fg="var(--color-text)">{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text> <text fg={theme.text}>{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
</box> </box>
<text fg="var(--color-muted)">[Left/Right]</text> <text fg={theme.textMuted}>[Left/Right]</text>
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "font" ? "var(--color-primary)" : "var(--color-muted)"}>Font Size:</text> <text fg={focusField() === "font" ? theme.primary : theme.textMuted}>Font Size:</text>
<box border padding={0}> <box border padding={0}>
<text fg="var(--color-text)">{settings().fontSize}px</text> <text fg={theme.text}>{settings().fontSize}px</text>
</box> </box>
<text fg="var(--color-muted)">[Left/Right]</text> <text fg={theme.textMuted}>[Left/Right]</text>
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "speed" ? "var(--color-primary)" : "var(--color-muted)"}>Playback:</text> <text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>Playback:</text>
<box border padding={0}> <box border padding={0}>
<text fg="var(--color-text)">{settings().playbackSpeed}x</text> <text fg={theme.text}>{settings().playbackSpeed}x</text>
</box> </box>
<text fg="var(--color-muted)">[Left/Right]</text> <text fg={theme.textMuted}>[Left/Right]</text>
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "explicit" ? "var(--color-primary)" : "var(--color-muted)"}>Show Explicit:</text> <text fg={focusField() === "explicit" ? theme.primary : theme.textMuted}>Show Explicit:</text>
<box border padding={0}> <box border padding={0}>
<text fg={preferences().showExplicit ? "var(--color-success)" : "var(--color-muted)"}> <text fg={preferences().showExplicit ? theme.success : theme.textMuted}>
{preferences().showExplicit ? "On" : "Off"} {preferences().showExplicit ? "On" : "Off"}
</text> </text>
</box> </box>
<text fg="var(--color-muted)">[Space]</text> <text fg={theme.textMuted}>[Space]</text>
</box> </box>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "auto" ? "var(--color-primary)" : "var(--color-muted)"}>Auto Download:</text> <text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>Auto Download:</text>
<box border padding={0}> <box border padding={0}>
<text fg={preferences().autoDownload ? "var(--color-success)" : "var(--color-muted)"}> <text fg={preferences().autoDownload ? theme.success : theme.textMuted}>
{preferences().autoDownload ? "On" : "Off"} {preferences().autoDownload ? "On" : "Off"}
</text> </text>
</box> </box>
<text fg="var(--color-muted)">[Space]</text> <text fg={theme.textMuted}>[Space]</text>
</box> </box>
</box> </box>
<text fg="var(--color-muted)">Tab to move focus, Left/Right to adjust</text> <text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box> </box>
) )
} }

View File

@@ -1,6 +1,7 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { SourceManager } from "./SourceManager" import { SourceManager } from "./SourceManager"
import { useTheme } from "../context/ThemeContext"
import { PreferencesPanel } from "./PreferencesPanel" import { PreferencesPanel } from "./PreferencesPanel"
import { SyncPanel } from "./SyncPanel" import { SyncPanel } from "./SyncPanel"
@@ -21,6 +22,7 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [
] ]
export function SettingsScreen(props: SettingsScreenProps) { export function SettingsScreen(props: SettingsScreenProps) {
const { theme } = useTheme()
const [activeSection, setActiveSection] = createSignal<SectionId>("sync") const [activeSection, setActiveSection] = createSignal<SectionId>("sync")
useKeyboard((key) => { useKeyboard((key) => {
@@ -50,7 +52,7 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text> <text>
<strong>Settings</strong> <strong>Settings</strong>
</text> </text>
<text fg="var(--color-muted)">[Tab] Switch section | 1-4 jump | Esc up</text> <text fg={theme.textMuted}>[Tab] Switch section | 1-4 jump | Esc up</text>
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
@@ -58,10 +60,10 @@ export function SettingsScreen(props: SettingsScreenProps) {
<box <box
border border
padding={0} padding={0}
backgroundColor={activeSection() === section.id ? "var(--color-primary)" : undefined} backgroundColor={activeSection() === section.id ? theme.primary : undefined}
onMouseDown={() => setActiveSection(section.id)} onMouseDown={() => setActiveSection(section.id)}
> >
<text fg={activeSection() === section.id ? "var(--color-text)" : "var(--color-muted)"}> <text fg={activeSection() === section.id ? theme.text : theme.textMuted}>
[{index + 1}] {section.label} [{index + 1}] {section.label}
</text> </text>
</box> </box>
@@ -74,21 +76,21 @@ export function SettingsScreen(props: SettingsScreenProps) {
{activeSection() === "preferences" && <PreferencesPanel />} {activeSection() === "preferences" && <PreferencesPanel />}
{activeSection() === "account" && ( {activeSection() === "account" && (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg="var(--color-muted)">Account</text> <text fg={theme.textMuted}>Account</text>
<box flexDirection="row" gap={2} alignItems="center"> <box flexDirection="row" gap={2} alignItems="center">
<text fg="var(--color-muted)">Status:</text> <text fg={theme.textMuted}>Status:</text>
<text fg={props.accountStatus === "signed-in" ? "var(--color-success)" : "var(--color-warning)"}> <text fg={props.accountStatus === "signed-in" ? theme.success : theme.warning}>
{props.accountLabel} {props.accountLabel}
</text> </text>
</box> </box>
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}> <box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
<text fg="var(--color-primary)">[A] Manage Account</text> <text fg={theme.primary}>[A] Manage Account</text>
</box> </box>
</box> </box>
)} )}
</box> </box>
<text fg="var(--color-muted)">Enter to dive | Esc up</text> <text fg={theme.textMuted}>Enter to dive | Esc up</text>
</box> </box>
) )
} }

View File

@@ -5,6 +5,7 @@
import { createSignal, For } from "solid-js" import { createSignal, For } from "solid-js"
import { useFeedStore } from "../stores/feed" import { useFeedStore } from "../stores/feed"
import { useTheme } from "../context/ThemeContext"
import { SourceType } from "../types/source" import { SourceType } from "../types/source"
import type { PodcastSource } 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) { export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore() const feedStore = useFeedStore()
const { theme } = useTheme()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusArea, setFocusArea] = createSignal<FocusArea>("list") const [focusArea, setFocusArea] = createSignal<FocusArea>("list")
const [newSourceUrl, setNewSourceUrl] = createSignal("") const [newSourceUrl, setNewSourceUrl] = createSignal("")
@@ -155,15 +157,15 @@ export function SourceManager(props: SourceManagerProps) {
<strong>Podcast Sources</strong> <strong>Podcast Sources</strong>
</text> </text>
<box border padding={0} onMouseDown={props.onClose}> <box border padding={0} onMouseDown={props.onClose}>
<text fg="var(--color-primary)">[Esc] Close</text> <text fg={theme.primary}>[Esc] Close</text>
</box> </box>
</box> </box>
<text fg="var(--color-muted)">Manage where to search for podcasts</text> <text fg={theme.textMuted}>Manage where to search for podcasts</text>
{/* Source list */} {/* Source list */}
<box border padding={1} flexDirection="column" gap={1}> <box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? "var(--color-primary)" : "var(--color-muted)"}>Sources:</text> <text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>Sources:</text>
<scrollbox height={6}> <scrollbox height={6}>
<For each={sources()}> <For each={sources()}>
{(source, index) => ( {(source, index) => (
@@ -173,7 +175,7 @@ export function SourceManager(props: SourceManagerProps) {
padding={0} padding={0}
backgroundColor={ backgroundColor={
focusArea() === "list" && index() === selectedIndex() focusArea() === "list" && index() === selectedIndex()
? "var(--color-primary)" ? theme.primary
: undefined : undefined
} }
onMouseDown={() => { onMouseDown={() => {
@@ -184,21 +186,21 @@ export function SourceManager(props: SourceManagerProps) {
> >
<text fg={ <text fg={
focusArea() === "list" && index() === selectedIndex() focusArea() === "list" && index() === selectedIndex()
? "var(--color-primary)" ? theme.primary
: "var(--color-muted)" : theme.textMuted
}> }>
{focusArea() === "list" && index() === selectedIndex() {focusArea() === "list" && index() === selectedIndex()
? ">" ? ">"
: " "} : " "}
</text> </text>
<text fg={source.enabled ? "var(--color-success)" : "var(--color-error)"}> <text fg={source.enabled ? theme.success : theme.error}>
{source.enabled ? "[x]" : "[ ]"} {source.enabled ? "[x]" : "[ ]"}
</text> </text>
<text fg="var(--color-accent)">{getSourceIcon(source)}</text> <text fg={theme.accent}>{getSourceIcon(source)}</text>
<text <text
fg={ fg={
focusArea() === "list" && index() === selectedIndex() focusArea() === "list" && index() === selectedIndex()
? "var(--color-text)" ? theme.text
: undefined : undefined
} }
> >
@@ -208,54 +210,54 @@ export function SourceManager(props: SourceManagerProps) {
)} )}
</For> </For>
</scrollbox> </scrollbox>
<text fg="var(--color-muted)">Space/Enter to toggle, d to delete, a to add</text> <text fg={theme.textMuted}>Space/Enter to toggle, d to delete, a to add</text>
{/* API settings */} {/* API settings */}
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={isApiSource() ? "var(--color-muted)" : "var(--color-accent)"}> <text fg={isApiSource() ? theme.textMuted : theme.accent}>
{isApiSource() ? "API Settings" : "API Settings (select an API source)"} {isApiSource() ? "API Settings" : "API Settings (select an API source)"}
</text> </text>
<box flexDirection="row" gap={2}> <box flexDirection="row" gap={2}>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "country" ? "var(--color-primary)" : undefined} backgroundColor={focusArea() === "country" ? theme.primary : undefined}
> >
<text fg={focusArea() === "country" ? "var(--color-primary)" : "var(--color-muted)"}> <text fg={focusArea() === "country" ? theme.primary : theme.textMuted}>
Country: {sourceCountry()} Country: {sourceCountry()}
</text> </text>
</box> </box>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "language" ? "var(--color-primary)" : undefined} backgroundColor={focusArea() === "language" ? theme.primary : undefined}
> >
<text fg={focusArea() === "language" ? "var(--color-primary)" : "var(--color-muted)"}> <text fg={focusArea() === "language" ? theme.primary : theme.textMuted}>
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</text> </text>
</box> </box>
<box <box
border border
padding={0} padding={0}
backgroundColor={focusArea() === "explicit" ? "var(--color-primary)" : undefined} backgroundColor={focusArea() === "explicit" ? theme.primary : undefined}
> >
<text fg={focusArea() === "explicit" ? "var(--color-primary)" : "var(--color-muted)"}> <text fg={focusArea() === "explicit" ? theme.primary : theme.textMuted}>
Explicit: {sourceExplicit() ? "Yes" : "No"} Explicit: {sourceExplicit() ? "Yes" : "No"}
</text> </text>
</box> </box>
</box> </box>
<text fg="var(--color-muted)">Enter/Space to toggle focused setting</text> <text fg={theme.textMuted}>Enter/Space to toggle focused setting</text>
</box> </box>
</box> </box>
{/* Add new source form */} {/* Add new source form */}
<box border padding={1} flexDirection="column" gap={1}> <box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "add" || focusArea() === "url" ? "var(--color-primary)" : "var(--color-muted)"}> <text fg={focusArea() === "add" || focusArea() === "url" ? theme.primary : theme.textMuted}>
Add New Source: Add New Source:
</text> </text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg="var(--color-muted)">Name:</text> <text fg={theme.textMuted}>Name:</text>
<input <input
value={newSourceName()} value={newSourceName()}
onInput={setNewSourceName} onInput={setNewSourceName}
@@ -266,7 +268,7 @@ export function SourceManager(props: SourceManagerProps) {
</box> </box>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<text fg="var(--color-muted)">URL:</text> <text fg={theme.textMuted}>URL:</text>
<input <input
value={newSourceUrl()} value={newSourceUrl()}
onInput={(v) => { onInput={(v) => {
@@ -285,16 +287,16 @@ export function SourceManager(props: SourceManagerProps) {
width={15} width={15}
onMouseDown={handleAddSource} onMouseDown={handleAddSource}
> >
<text fg="var(--color-success)">[+] Add Source</text> <text fg={theme.success}>[+] Add Source</text>
</box> </box>
</box> </box>
{/* Error message */} {/* Error message */}
{error() && ( {error() && (
<text fg="var(--color-error)">{error()}</text> <text fg={theme.error}>{error()}</text>
)} )}
<text fg="var(--color-muted)">Tab to switch sections, Esc to close</text> <text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
</box> </box>
) )
} }

View File

@@ -1,3 +1,5 @@
import { useTheme } from "../context/ThemeContext"
export type TabId = "discover" | "feeds" | "search" | "player" | "settings" export type TabId = "discover" | "feeds" | "search" | "player" | "settings"
export type TabDefinition = { export type TabDefinition = {
@@ -20,11 +22,12 @@ type TabProps = {
} }
export function Tab(props: TabProps) { export function Tab(props: TabProps) {
const { theme } = useTheme()
return ( return (
<box <box
border border
onMouseDown={() => props.onSelect(props.tab.id)} onMouseDown={() => props.onSelect(props.tab.id)}
style={{ padding: 1, backgroundColor: props.active ? "var(--color-primary)" : "transparent" }} style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }}
> >
<text> <text>
{props.active ? "[" : " "} {props.active ? "[" : " "}

View File

@@ -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 { createStore, produce } from "solid-js/store"
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
import type { ThemeName } from "../types/settings" import type { ThemeName } from "../types/settings"
@@ -7,13 +7,76 @@ import { useAppStore } from "../stores/app"
import { THEME_JSON } from "../constants/themes" import { THEME_JSON } from "../constants/themes"
import { resolveTheme } from "../utils/theme-resolver" import { resolveTheme } from "../utils/theme-resolver"
import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter"
import { generateSystemTheme } from "../utils/system-theme" import { resolveTerminalTheme, loadThemes } from "../utils/theme"
import { getCustomThemes } from "../utils/custom-themes" import type { RGBA, TerminalColors } from "@opentui/core"
import { setThemeAttribute } from "../utils/theme"
import type { RGBA } 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 = { type ThemeContextValue = {
theme: Record<string, unknown> theme: ThemeResolved
selected: () => string selected: () => string
all: () => Record<string, ThemeJson> all: () => Record<string, ThemeJson>
syntax: () => unknown syntax: () => unknown
@@ -31,13 +94,14 @@ export function ThemeProvider({ children }: { children: any }) {
const renderer = useRenderer() const renderer = useRenderer()
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: { ...THEME_JSON } as Record<string, ThemeJson>, themes: {} as Record<string, ThemeJson>,
mode: "dark" as "dark" | "light", mode: "dark" as "dark" | "light",
active: appStore.state().settings.theme as ThemeName, active: appStore.state().settings.theme as ThemeName,
system: undefined as undefined | TerminalColors,
}) })
const init = () => { const init = () => {
getCustomThemes() loadThemes()
.then((custom) => { .then((custom) => {
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -52,26 +116,18 @@ export function ThemeProvider({ children }: { children: any }) {
createEffect(() => { createEffect(() => {
setStore("active", appStore.state().settings.theme) setStore("active", appStore.state().settings.theme)
setThemeAttribute(appStore.state().settings.theme)
}) })
createEffect(() => { createEffect(() => {
if (store.active !== "system") return
renderer renderer
.getPalette({ size: 16 }) .getPalette({ size: 16 })
.then((colors) => { .then((colors) => setStore("system", colors))
setStore(
produce((draft) => {
draft.themes.system = generateSystemTheme(colors, store.mode)
})
)
})
.catch(() => {}) .catch(() => {})
}) })
const values = createMemo(() => { const values = createMemo(() => {
const theme = store.themes[store.active] ?? store.themes.opencode const themes = Object.keys(store.themes).length ? store.themes : THEME_JSON
return resolveTheme(theme, store.mode) return resolveTerminalTheme(themes, store.active, store.mode, store.system)
}) })
const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>)) const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>))
@@ -80,11 +136,11 @@ export function ThemeProvider({ children }: { children: any }) {
) )
const context: ThemeContextValue = { const context: ThemeContextValue = {
theme: new Proxy(values(), { theme: new Proxy(values(), {
get(_target, prop) { get(_target, prop) {
return values()[prop as keyof typeof values] return values()[prop as keyof typeof values]
}, },
}), }) as ThemeResolved,
selected: () => store.active, selected: () => store.active,
all: () => store.themes, all: () => store.themes,
syntax, syntax,
@@ -95,7 +151,11 @@ export function ThemeProvider({ children }: { children: any }) {
ready, ready,
} }
return <ThemeContext.Provider value={context}>{children}</ThemeContext.Provider> return (
<Show when={ready()}>
<ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
</Show>
)
} }
export function useTheme() { export function useTheme() {

View File

@@ -1,5 +1,10 @@
import { render } from "@opentui/solid" import { render } from "@opentui/solid"
import { App } from "./App" import { App } from "./App"
import { ThemeProvider } from "./context/ThemeContext"
import "./styles/theme.css" import "./styles/theme.css"
render(() => <App />) render(() => (
<ThemeProvider>
<App />
</ThemeProvider>
))

View File

@@ -1,23 +1,30 @@
import path from "path" import path from "path"
import { mkdir } from "fs/promises"
import type { ThemeJson } from "../types/theme-schema" import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
import { validateTheme } from "./theme-loader" import { validateTheme } from "./theme-loader"
export async function getCustomThemes() { export async function getCustomThemes() {
const dirs = [ const home = process.env.HOME ?? ""
path.join(process.env.HOME ?? "", ".config/podtui/themes"), if (!home) return {}
path.resolve(process.cwd(), ".podtui/themes"),
path.resolve(process.cwd(), "themes"), 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<string, ThemeJson> = {} const result: Record<string, ThemeJson> = {}
for (const dir of dirs) { const glob = new Bun.Glob("*.json")
const glob = new Bun.Glob("*.json") for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) { const name = path.basename(item, ".json")
const name = path.basename(item, ".json") const json = (await Bun.file(item).json()) as ThemeJson
const json = (await Bun.file(item).json()) as ThemeJson validateTheme(json, item)
validateTheme(json, item) result[name] = json
result[name] = json
}
} }
return result return result
} }

View File

@@ -5,6 +5,9 @@ import { ansiToRgba } from "./ansi-to-rgba"
export type ThemeMode = "dark" | "light" export type ThemeMode = "dark" | "light"
export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
if (!theme || !theme.theme) {
throw new Error("Invalid theme: missing theme object")
}
const defs = theme.defs ?? {} const defs = theme.defs ?? {}
function resolveColor(value: ColorValue): RGBA { function resolveColor(value: ColorValue): RGBA {
@@ -38,8 +41,21 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) {
const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 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 { return {
...resolved, ...resolved,
muted: resolved.textMuted ?? resolved.muted,
surface: resolved.backgroundPanel ?? resolved.surface,
layerBackgrounds: {
layer0: background,
layer1: backgroundPanel,
layer2: backgroundElement,
layer3: backgroundMenu,
},
_hasSelectedListItemText: hasSelected, _hasSelectedListItemText: hasSelected,
thinkingOpacity, thinkingOpacity,
} }

View File

@@ -3,9 +3,13 @@
* Handles dynamic theme switching by updating CSS custom properties * 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 { 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) => { const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) { if (value instanceof RGBA) {
@@ -60,3 +64,32 @@ export function setThemeAttribute(themeName: string) {
const root = document.documentElement const root = document.documentElement
root.setAttribute("data-theme", themeName) 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<string, ThemeJson>,
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)
}