Compare commits
4 Commits
c26150221a
...
9fa52d71ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fa52d71ca | |||
| ea9ab4d3f9 | |||
| 6950deaa88 | |||
| 4579659784 |
21
src/App.tsx
21
src/App.tsx
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import type { ThemeColors, LayerBackgrounds } from "../types/settings"
|
import type { RGBA } from "@opentui/core"
|
||||||
|
import { useTheme } from "../context/ThemeContext"
|
||||||
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()
|
||||||
|
|
||||||
// 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: backgrounds.layer0 },
|
0: { depth: 0, background: backgrounds?.layer0 ?? theme.background },
|
||||||
1: { depth: 1, background: backgrounds.layer1 },
|
1: { depth: 1, background: backgrounds?.layer1 ?? theme.backgroundPanel },
|
||||||
2: { depth: 2, background: backgrounds.layer2 },
|
2: { depth: 2, background: backgrounds?.layer2 ?? theme.backgroundElement },
|
||||||
3: { depth: 3, background: 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
|
||||||
@@ -43,14 +39,14 @@ export function Layout(props: LayoutProps) {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={theme?.background}
|
backgroundColor={theme.background}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{props.header ? (
|
{props.header ? (
|
||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
height: 4,
|
height: 4,
|
||||||
backgroundColor: theme?.surface,
|
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>
|
<box style={{ padding: 1 }}>
|
||||||
@@ -80,7 +76,7 @@ export function Layout(props: LayoutProps) {
|
|||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
height: 2,
|
height: 2,
|
||||||
backgroundColor: theme?.surface,
|
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>
|
<box style={{ padding: 1 }}>
|
||||||
@@ -96,7 +92,7 @@ export function Layout(props: LayoutProps) {
|
|||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: theme?.surface,
|
backgroundColor: theme.surface ?? theme.backgroundPanel,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>
|
<box style={{ padding: 1 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? "[" : " "}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import type { ThemeColors, ThemeName } from "../types/settings"
|
import type { ThemeColors, ThemeDefinition, ThemeName } from "../types/settings"
|
||||||
import { BASE_THEME_COLORS, BASE_LAYER_BACKGROUND, THEMES_DESKTOP } from "../types/desktop-theme"
|
import { BASE_THEME_COLORS, BASE_LAYER_BACKGROUND } from "../types/desktop-theme"
|
||||||
|
import catppuccin from "../themes/catppuccin.json" with { type: "json" }
|
||||||
|
import gruvbox from "../themes/gruvbox.json" with { type: "json" }
|
||||||
|
import tokyo from "../themes/tokyo.json" with { type: "json" }
|
||||||
|
import nord from "../themes/nord.json" with { type: "json" }
|
||||||
|
import opencode from "../themes/opencode.json" with { type: "json" }
|
||||||
|
|
||||||
export const DEFAULT_THEME: ThemeColors = {
|
export const DEFAULT_THEME: ThemeColors = {
|
||||||
...BASE_THEME_COLORS,
|
...BASE_THEME_COLORS,
|
||||||
layerBackgrounds: BASE_LAYER_BACKGROUND,
|
layerBackgrounds: BASE_LAYER_BACKGROUND,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const THEMES: Record<ThemeName, ThemeColors> = {
|
export const THEME_JSON: Record<string, ThemeDefinition> = {
|
||||||
system: DEFAULT_THEME,
|
opencode: opencode as ThemeDefinition,
|
||||||
catppuccin: THEMES_DESKTOP.variants.find((v) => v.name === "catppuccin")!.colors,
|
catppuccin: catppuccin as ThemeDefinition,
|
||||||
gruvbox: THEMES_DESKTOP.variants.find((v) => v.name === "gruvbox")!.colors,
|
gruvbox: gruvbox as ThemeDefinition,
|
||||||
tokyo: THEMES_DESKTOP.variants.find((v) => v.name === "tokyo")!.colors,
|
tokyo: tokyo as ThemeDefinition,
|
||||||
nord: THEMES_DESKTOP.variants.find((v) => v.name === "nord")!.colors,
|
nord: nord as ThemeDefinition,
|
||||||
custom: DEFAULT_THEME,
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/context/ThemeContext.test.ts
Normal file
8
src/context/ThemeContext.test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { ThemeProvider } from "./ThemeContext"
|
||||||
|
|
||||||
|
describe("ThemeContext", () => {
|
||||||
|
it("exports provider", () => {
|
||||||
|
expect(typeof ThemeProvider).toBe("function")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,66 +1,160 @@
|
|||||||
import { createContext, useContext, createSignal, createEffect, onCleanup } from "solid-js"
|
import { createContext, createEffect, createMemo, createSignal, Show, useContext } from "solid-js"
|
||||||
import type { ThemeColors, ThemeName } from "../types/settings"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import { useRenderer } from "@opentui/solid"
|
||||||
|
import type { ThemeName } from "../types/settings"
|
||||||
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
import { useAppStore } from "../stores/app"
|
import { useAppStore } from "../stores/app"
|
||||||
import { applyTheme, setThemeAttribute, getSystemThemeMode } from "../utils/theme"
|
import { THEME_JSON } from "../constants/themes"
|
||||||
|
import { resolveTheme } from "../utils/theme-resolver"
|
||||||
|
import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter"
|
||||||
|
import { resolveTerminalTheme, loadThemes } from "../utils/theme"
|
||||||
|
import type { RGBA, TerminalColors } from "@opentui/core"
|
||||||
|
|
||||||
type ThemeContextType = {
|
type ThemeResolved = {
|
||||||
themeName: () => ThemeName
|
primary: RGBA
|
||||||
setThemeName: (theme: ThemeName) => void
|
secondary: RGBA
|
||||||
resolvedTheme: () => ThemeColors
|
accent: RGBA
|
||||||
isSystemTheme: () => boolean
|
error: RGBA
|
||||||
currentMode: () => "dark" | "light"
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType>()
|
type ThemeContextValue = {
|
||||||
|
theme: ThemeResolved
|
||||||
|
selected: () => string
|
||||||
|
all: () => Record<string, ThemeJson>
|
||||||
|
syntax: () => unknown
|
||||||
|
subtleSyntax: () => unknown
|
||||||
|
mode: () => "dark" | "light"
|
||||||
|
setMode: (mode: "dark" | "light") => void
|
||||||
|
set: (theme: string) => void
|
||||||
|
ready: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue>()
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: any }) {
|
export function ThemeProvider({ children }: { children: any }) {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const [themeName, setThemeName] = createSignal<ThemeName>(appStore.state().settings.theme)
|
const renderer = useRenderer()
|
||||||
const [resolvedTheme, setResolvedTheme] = createSignal<ThemeColors>(appStore.resolveTheme())
|
const [ready, setReady] = createSignal(false)
|
||||||
const [currentMode, setCurrentMode] = createSignal<"dark" | "light">(getSystemThemeMode())
|
const [store, setStore] = createStore({
|
||||||
|
themes: {} as Record<string, ThemeJson>,
|
||||||
const isSystemTheme = () => themeName() === "system"
|
mode: "dark" as "dark" | "light",
|
||||||
|
active: appStore.state().settings.theme as ThemeName,
|
||||||
// Update theme when appStore theme changes
|
system: undefined as undefined | TerminalColors,
|
||||||
createEffect(() => {
|
|
||||||
const currentTheme = appStore.state().settings.theme
|
|
||||||
setThemeName(currentTheme)
|
|
||||||
setResolvedTheme(appStore.resolveTheme())
|
|
||||||
|
|
||||||
// Apply theme to CSS variables
|
|
||||||
if (currentTheme === "system") {
|
|
||||||
const mode = getSystemThemeMode()
|
|
||||||
setCurrentMode(mode)
|
|
||||||
applyTheme(resolvedTheme())
|
|
||||||
} else {
|
|
||||||
setCurrentMode("dark") // All themes are dark by default
|
|
||||||
}
|
|
||||||
|
|
||||||
setThemeAttribute(currentTheme === "system" ? "system" : currentTheme)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle system theme changes
|
const init = () => {
|
||||||
createEffect(() => {
|
loadThemes()
|
||||||
if (isSystemTheme()) {
|
.then((custom) => {
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
setStore(
|
||||||
const handler = () => {
|
produce((draft) => {
|
||||||
const newMode = getSystemThemeMode()
|
Object.assign(draft.themes, custom)
|
||||||
setCurrentMode(newMode)
|
})
|
||||||
setResolvedTheme(appStore.resolveTheme())
|
)
|
||||||
}
|
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handler)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
mediaQuery.removeEventListener("change", handler)
|
|
||||||
})
|
})
|
||||||
}
|
.finally(() => setReady(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setStore("active", appStore.state().settings.theme)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
renderer
|
||||||
|
.getPalette({ size: 16 })
|
||||||
|
.then((colors) => setStore("system", colors))
|
||||||
|
.catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = createMemo(() => {
|
||||||
|
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<string, RGBA>))
|
||||||
|
const subtleSyntax = createMemo(() =>
|
||||||
|
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { thinkingOpacity?: number })
|
||||||
|
)
|
||||||
|
|
||||||
|
const context: ThemeContextValue = {
|
||||||
|
theme: new Proxy(values(), {
|
||||||
|
get(_target, prop) {
|
||||||
|
return values()[prop as keyof typeof values]
|
||||||
|
},
|
||||||
|
}) as ThemeResolved,
|
||||||
|
selected: () => store.active,
|
||||||
|
all: () => store.themes,
|
||||||
|
syntax,
|
||||||
|
subtleSyntax,
|
||||||
|
mode: () => store.mode,
|
||||||
|
setMode: (mode) => setStore("mode", mode),
|
||||||
|
set: (theme) => appStore.setTheme(theme as ThemeName),
|
||||||
|
ready,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ themeName, setThemeName, resolvedTheme, isSystemTheme, currentMode }}>
|
<Show when={ready()}>
|
||||||
{children}
|
<ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
|
||||||
</ThemeContext.Provider>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { DEFAULT_THEME, THEMES } from "../constants/themes"
|
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes"
|
||||||
import type { AppSettings, AppState, ThemeColors, ThemeName, UserPreferences } from "../types/settings"
|
import type { AppSettings, AppState, ThemeColors, ThemeName, ThemeMode, UserPreferences } from "../types/settings"
|
||||||
|
import { resolveTheme } from "../utils/theme-resolver"
|
||||||
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
|
|
||||||
const STORAGE_KEY = "podtui_app_state"
|
const STORAGE_KEY = "podtui_app_state"
|
||||||
|
|
||||||
@@ -83,10 +85,13 @@ export function createAppStore() {
|
|||||||
updateSettings({ theme })
|
updateSettings({ theme })
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveTheme = (): ThemeColors => {
|
const resolveThemeColors = (): ThemeColors => {
|
||||||
const theme = state().settings.theme
|
const theme = state().settings.theme
|
||||||
if (theme === "custom") return state().customTheme
|
if (theme === "custom") return state().customTheme
|
||||||
return THEMES[theme] ?? DEFAULT_THEME
|
if (theme === "system") return DEFAULT_THEME
|
||||||
|
const json = THEME_JSON[theme]
|
||||||
|
if (!json) return DEFAULT_THEME
|
||||||
|
return resolveTheme(json as ThemeJson, "dark" as ThemeMode) as unknown as ThemeColors
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +100,7 @@ export function createAppStore() {
|
|||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateCustomTheme,
|
updateCustomTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
resolveTheme,
|
resolveTheme: resolveThemeColors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
src/themes/catppuccin.json
Normal file
74
src/themes/catppuccin.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"background": "transparent",
|
||||||
|
"surface": "#1e1e2e",
|
||||||
|
"primary": "#89b4fa",
|
||||||
|
"secondary": "#cba6f7",
|
||||||
|
"accent": "#f9e2af",
|
||||||
|
"text": "#cdd6f4",
|
||||||
|
"muted": "#7f849c",
|
||||||
|
"warning": "#fab387",
|
||||||
|
"error": "#f38ba8",
|
||||||
|
"success": "#a6e3a1",
|
||||||
|
"layer0": "transparent",
|
||||||
|
"layer1": "#181825",
|
||||||
|
"layer2": "#11111b",
|
||||||
|
"layer3": "#0a0a0f"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": "primary",
|
||||||
|
"secondary": "secondary",
|
||||||
|
"accent": "accent",
|
||||||
|
"error": "error",
|
||||||
|
"warning": "warning",
|
||||||
|
"success": "success",
|
||||||
|
"info": "secondary",
|
||||||
|
"text": "text",
|
||||||
|
"textMuted": "muted",
|
||||||
|
"selectedListItemText": "background",
|
||||||
|
"background": "background",
|
||||||
|
"backgroundPanel": "surface",
|
||||||
|
"backgroundElement": "layer1",
|
||||||
|
"backgroundMenu": "layer1",
|
||||||
|
"border": "muted",
|
||||||
|
"borderActive": "primary",
|
||||||
|
"borderSubtle": "muted",
|
||||||
|
"diffAdded": "success",
|
||||||
|
"diffRemoved": "error",
|
||||||
|
"diffContext": "muted",
|
||||||
|
"diffHunkHeader": "muted",
|
||||||
|
"diffHighlightAdded": "success",
|
||||||
|
"diffHighlightRemoved": "error",
|
||||||
|
"diffAddedBg": "layer2",
|
||||||
|
"diffRemovedBg": "layer3",
|
||||||
|
"diffContextBg": "layer1",
|
||||||
|
"diffLineNumber": "muted",
|
||||||
|
"diffAddedLineNumberBg": "layer2",
|
||||||
|
"diffRemovedLineNumberBg": "layer3",
|
||||||
|
"markdownText": "text",
|
||||||
|
"markdownHeading": "accent",
|
||||||
|
"markdownLink": "primary",
|
||||||
|
"markdownLinkText": "secondary",
|
||||||
|
"markdownCode": "success",
|
||||||
|
"markdownBlockQuote": "warning",
|
||||||
|
"markdownEmph": "warning",
|
||||||
|
"markdownStrong": "accent",
|
||||||
|
"markdownHorizontalRule": "muted",
|
||||||
|
"markdownListItem": "primary",
|
||||||
|
"markdownListEnumeration": "secondary",
|
||||||
|
"markdownImage": "primary",
|
||||||
|
"markdownImageText": "secondary",
|
||||||
|
"markdownCodeBlock": "text",
|
||||||
|
"syntaxComment": "muted",
|
||||||
|
"syntaxKeyword": "accent",
|
||||||
|
"syntaxFunction": "primary",
|
||||||
|
"syntaxVariable": "secondary",
|
||||||
|
"syntaxString": "success",
|
||||||
|
"syntaxNumber": "warning",
|
||||||
|
"syntaxType": "accent",
|
||||||
|
"syntaxOperator": "secondary",
|
||||||
|
"syntaxPunctuation": "text",
|
||||||
|
"thinkingOpacity": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/themes/gruvbox.json
Normal file
74
src/themes/gruvbox.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"background": "transparent",
|
||||||
|
"surface": "#282828",
|
||||||
|
"primary": "#fabd2f",
|
||||||
|
"secondary": "#83a598",
|
||||||
|
"accent": "#fe8019",
|
||||||
|
"text": "#ebdbb2",
|
||||||
|
"muted": "#928374",
|
||||||
|
"warning": "#fabd2f",
|
||||||
|
"error": "#fb4934",
|
||||||
|
"success": "#b8bb26",
|
||||||
|
"layer0": "transparent",
|
||||||
|
"layer1": "#32302a",
|
||||||
|
"layer2": "#1d2021",
|
||||||
|
"layer3": "#0d0c0c"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": "primary",
|
||||||
|
"secondary": "secondary",
|
||||||
|
"accent": "accent",
|
||||||
|
"error": "error",
|
||||||
|
"warning": "warning",
|
||||||
|
"success": "success",
|
||||||
|
"info": "secondary",
|
||||||
|
"text": "text",
|
||||||
|
"textMuted": "muted",
|
||||||
|
"selectedListItemText": "background",
|
||||||
|
"background": "background",
|
||||||
|
"backgroundPanel": "surface",
|
||||||
|
"backgroundElement": "layer1",
|
||||||
|
"backgroundMenu": "layer1",
|
||||||
|
"border": "muted",
|
||||||
|
"borderActive": "primary",
|
||||||
|
"borderSubtle": "muted",
|
||||||
|
"diffAdded": "success",
|
||||||
|
"diffRemoved": "error",
|
||||||
|
"diffContext": "muted",
|
||||||
|
"diffHunkHeader": "muted",
|
||||||
|
"diffHighlightAdded": "success",
|
||||||
|
"diffHighlightRemoved": "error",
|
||||||
|
"diffAddedBg": "layer2",
|
||||||
|
"diffRemovedBg": "layer3",
|
||||||
|
"diffContextBg": "layer1",
|
||||||
|
"diffLineNumber": "muted",
|
||||||
|
"diffAddedLineNumberBg": "layer2",
|
||||||
|
"diffRemovedLineNumberBg": "layer3",
|
||||||
|
"markdownText": "text",
|
||||||
|
"markdownHeading": "accent",
|
||||||
|
"markdownLink": "primary",
|
||||||
|
"markdownLinkText": "secondary",
|
||||||
|
"markdownCode": "success",
|
||||||
|
"markdownBlockQuote": "warning",
|
||||||
|
"markdownEmph": "warning",
|
||||||
|
"markdownStrong": "accent",
|
||||||
|
"markdownHorizontalRule": "muted",
|
||||||
|
"markdownListItem": "primary",
|
||||||
|
"markdownListEnumeration": "secondary",
|
||||||
|
"markdownImage": "primary",
|
||||||
|
"markdownImageText": "secondary",
|
||||||
|
"markdownCodeBlock": "text",
|
||||||
|
"syntaxComment": "muted",
|
||||||
|
"syntaxKeyword": "accent",
|
||||||
|
"syntaxFunction": "primary",
|
||||||
|
"syntaxVariable": "secondary",
|
||||||
|
"syntaxString": "success",
|
||||||
|
"syntaxNumber": "warning",
|
||||||
|
"syntaxType": "accent",
|
||||||
|
"syntaxOperator": "secondary",
|
||||||
|
"syntaxPunctuation": "text",
|
||||||
|
"thinkingOpacity": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/themes/nord.json
Normal file
74
src/themes/nord.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"background": "transparent",
|
||||||
|
"surface": "#2e3440",
|
||||||
|
"primary": "#88c0d0",
|
||||||
|
"secondary": "#81a1c1",
|
||||||
|
"accent": "#ebcb8b",
|
||||||
|
"text": "#eceff4",
|
||||||
|
"muted": "#4c566a",
|
||||||
|
"warning": "#ebcb8b",
|
||||||
|
"error": "#bf616a",
|
||||||
|
"success": "#a3be8c",
|
||||||
|
"layer0": "transparent",
|
||||||
|
"layer1": "#3b4252",
|
||||||
|
"layer2": "#242933",
|
||||||
|
"layer3": "#1a1c23"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": "primary",
|
||||||
|
"secondary": "secondary",
|
||||||
|
"accent": "accent",
|
||||||
|
"error": "error",
|
||||||
|
"warning": "warning",
|
||||||
|
"success": "success",
|
||||||
|
"info": "secondary",
|
||||||
|
"text": "text",
|
||||||
|
"textMuted": "muted",
|
||||||
|
"selectedListItemText": "background",
|
||||||
|
"background": "background",
|
||||||
|
"backgroundPanel": "surface",
|
||||||
|
"backgroundElement": "layer1",
|
||||||
|
"backgroundMenu": "layer1",
|
||||||
|
"border": "muted",
|
||||||
|
"borderActive": "primary",
|
||||||
|
"borderSubtle": "muted",
|
||||||
|
"diffAdded": "success",
|
||||||
|
"diffRemoved": "error",
|
||||||
|
"diffContext": "muted",
|
||||||
|
"diffHunkHeader": "muted",
|
||||||
|
"diffHighlightAdded": "success",
|
||||||
|
"diffHighlightRemoved": "error",
|
||||||
|
"diffAddedBg": "layer2",
|
||||||
|
"diffRemovedBg": "layer3",
|
||||||
|
"diffContextBg": "layer1",
|
||||||
|
"diffLineNumber": "muted",
|
||||||
|
"diffAddedLineNumberBg": "layer2",
|
||||||
|
"diffRemovedLineNumberBg": "layer3",
|
||||||
|
"markdownText": "text",
|
||||||
|
"markdownHeading": "accent",
|
||||||
|
"markdownLink": "primary",
|
||||||
|
"markdownLinkText": "secondary",
|
||||||
|
"markdownCode": "success",
|
||||||
|
"markdownBlockQuote": "warning",
|
||||||
|
"markdownEmph": "warning",
|
||||||
|
"markdownStrong": "accent",
|
||||||
|
"markdownHorizontalRule": "muted",
|
||||||
|
"markdownListItem": "primary",
|
||||||
|
"markdownListEnumeration": "secondary",
|
||||||
|
"markdownImage": "primary",
|
||||||
|
"markdownImageText": "secondary",
|
||||||
|
"markdownCodeBlock": "text",
|
||||||
|
"syntaxComment": "muted",
|
||||||
|
"syntaxKeyword": "accent",
|
||||||
|
"syntaxFunction": "primary",
|
||||||
|
"syntaxVariable": "secondary",
|
||||||
|
"syntaxString": "success",
|
||||||
|
"syntaxNumber": "warning",
|
||||||
|
"syntaxType": "accent",
|
||||||
|
"syntaxOperator": "secondary",
|
||||||
|
"syntaxPunctuation": "text",
|
||||||
|
"thinkingOpacity": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
245
src/themes/opencode.json
Normal file
245
src/themes/opencode.json
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"darkStep1": "#0a0a0a",
|
||||||
|
"darkStep2": "#141414",
|
||||||
|
"darkStep3": "#1e1e1e",
|
||||||
|
"darkStep4": "#282828",
|
||||||
|
"darkStep5": "#323232",
|
||||||
|
"darkStep6": "#3c3c3c",
|
||||||
|
"darkStep7": "#484848",
|
||||||
|
"darkStep8": "#606060",
|
||||||
|
"darkStep9": "#fab283",
|
||||||
|
"darkStep10": "#ffc09f",
|
||||||
|
"darkStep11": "#808080",
|
||||||
|
"darkStep12": "#eeeeee",
|
||||||
|
"darkSecondary": "#5c9cf5",
|
||||||
|
"darkAccent": "#9d7cd8",
|
||||||
|
"darkRed": "#e06c75",
|
||||||
|
"darkOrange": "#f5a742",
|
||||||
|
"darkGreen": "#7fd88f",
|
||||||
|
"darkCyan": "#56b6c2",
|
||||||
|
"darkYellow": "#e5c07b",
|
||||||
|
"lightStep1": "#ffffff",
|
||||||
|
"lightStep2": "#fafafa",
|
||||||
|
"lightStep3": "#f5f5f5",
|
||||||
|
"lightStep4": "#ebebeb",
|
||||||
|
"lightStep5": "#e1e1e1",
|
||||||
|
"lightStep6": "#d4d4d4",
|
||||||
|
"lightStep7": "#b8b8b8",
|
||||||
|
"lightStep8": "#a0a0a0",
|
||||||
|
"lightStep9": "#3b7dd8",
|
||||||
|
"lightStep10": "#2968c3",
|
||||||
|
"lightStep11": "#8a8a8a",
|
||||||
|
"lightStep12": "#1a1a1a",
|
||||||
|
"lightSecondary": "#7b5bb6",
|
||||||
|
"lightAccent": "#d68c27",
|
||||||
|
"lightRed": "#d1383d",
|
||||||
|
"lightOrange": "#d68c27",
|
||||||
|
"lightGreen": "#3d9a57",
|
||||||
|
"lightCyan": "#318795",
|
||||||
|
"lightYellow": "#b0851f"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": {
|
||||||
|
"dark": "darkStep9",
|
||||||
|
"light": "lightStep9"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"dark": "darkSecondary",
|
||||||
|
"light": "lightSecondary"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"dark": "darkAccent",
|
||||||
|
"light": "lightAccent"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"dark": "darkRed",
|
||||||
|
"light": "lightRed"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"dark": "darkOrange",
|
||||||
|
"light": "lightOrange"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"dark": "darkGreen",
|
||||||
|
"light": "lightGreen"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"dark": "darkCyan",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"dark": "darkStep12",
|
||||||
|
"light": "lightStep12"
|
||||||
|
},
|
||||||
|
"textMuted": {
|
||||||
|
"dark": "darkStep11",
|
||||||
|
"light": "lightStep11"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"dark": "darkStep1",
|
||||||
|
"light": "lightStep1"
|
||||||
|
},
|
||||||
|
"backgroundPanel": {
|
||||||
|
"dark": "darkStep2",
|
||||||
|
"light": "lightStep2"
|
||||||
|
},
|
||||||
|
"backgroundElement": {
|
||||||
|
"dark": "darkStep3",
|
||||||
|
"light": "lightStep3"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"dark": "darkStep7",
|
||||||
|
"light": "lightStep7"
|
||||||
|
},
|
||||||
|
"borderActive": {
|
||||||
|
"dark": "darkStep8",
|
||||||
|
"light": "lightStep8"
|
||||||
|
},
|
||||||
|
"borderSubtle": {
|
||||||
|
"dark": "darkStep6",
|
||||||
|
"light": "lightStep6"
|
||||||
|
},
|
||||||
|
"diffAdded": {
|
||||||
|
"dark": "#4fd6be",
|
||||||
|
"light": "#1e725c"
|
||||||
|
},
|
||||||
|
"diffRemoved": {
|
||||||
|
"dark": "#c53b53",
|
||||||
|
"light": "#c53b53"
|
||||||
|
},
|
||||||
|
"diffContext": {
|
||||||
|
"dark": "#828bb8",
|
||||||
|
"light": "#7086b5"
|
||||||
|
},
|
||||||
|
"diffHunkHeader": {
|
||||||
|
"dark": "#828bb8",
|
||||||
|
"light": "#7086b5"
|
||||||
|
},
|
||||||
|
"diffHighlightAdded": {
|
||||||
|
"dark": "#b8db87",
|
||||||
|
"light": "#4db380"
|
||||||
|
},
|
||||||
|
"diffHighlightRemoved": {
|
||||||
|
"dark": "#e26a75",
|
||||||
|
"light": "#f52a65"
|
||||||
|
},
|
||||||
|
"diffAddedBg": {
|
||||||
|
"dark": "#20303b",
|
||||||
|
"light": "#d5e5d5"
|
||||||
|
},
|
||||||
|
"diffRemovedBg": {
|
||||||
|
"dark": "#37222c",
|
||||||
|
"light": "#f7d8db"
|
||||||
|
},
|
||||||
|
"diffContextBg": {
|
||||||
|
"dark": "darkStep2",
|
||||||
|
"light": "lightStep2"
|
||||||
|
},
|
||||||
|
"diffLineNumber": {
|
||||||
|
"dark": "darkStep3",
|
||||||
|
"light": "lightStep3"
|
||||||
|
},
|
||||||
|
"diffAddedLineNumberBg": {
|
||||||
|
"dark": "#1b2b34",
|
||||||
|
"light": "#c5d5c5"
|
||||||
|
},
|
||||||
|
"diffRemovedLineNumberBg": {
|
||||||
|
"dark": "#2d1f26",
|
||||||
|
"light": "#e7c8cb"
|
||||||
|
},
|
||||||
|
"markdownText": {
|
||||||
|
"dark": "darkStep12",
|
||||||
|
"light": "lightStep12"
|
||||||
|
},
|
||||||
|
"markdownHeading": {
|
||||||
|
"dark": "darkAccent",
|
||||||
|
"light": "lightAccent"
|
||||||
|
},
|
||||||
|
"markdownLink": {
|
||||||
|
"dark": "darkStep9",
|
||||||
|
"light": "lightStep9"
|
||||||
|
},
|
||||||
|
"markdownLinkText": {
|
||||||
|
"dark": "darkCyan",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"markdownCode": {
|
||||||
|
"dark": "darkGreen",
|
||||||
|
"light": "lightGreen"
|
||||||
|
},
|
||||||
|
"markdownBlockQuote": {
|
||||||
|
"dark": "darkYellow",
|
||||||
|
"light": "lightYellow"
|
||||||
|
},
|
||||||
|
"markdownEmph": {
|
||||||
|
"dark": "darkYellow",
|
||||||
|
"light": "lightYellow"
|
||||||
|
},
|
||||||
|
"markdownStrong": {
|
||||||
|
"dark": "darkOrange",
|
||||||
|
"light": "lightOrange"
|
||||||
|
},
|
||||||
|
"markdownHorizontalRule": {
|
||||||
|
"dark": "darkStep11",
|
||||||
|
"light": "lightStep11"
|
||||||
|
},
|
||||||
|
"markdownListItem": {
|
||||||
|
"dark": "darkStep9",
|
||||||
|
"light": "lightStep9"
|
||||||
|
},
|
||||||
|
"markdownListEnumeration": {
|
||||||
|
"dark": "darkCyan",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"markdownImage": {
|
||||||
|
"dark": "darkStep9",
|
||||||
|
"light": "lightStep9"
|
||||||
|
},
|
||||||
|
"markdownImageText": {
|
||||||
|
"dark": "darkCyan",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"markdownCodeBlock": {
|
||||||
|
"dark": "darkStep12",
|
||||||
|
"light": "lightStep12"
|
||||||
|
},
|
||||||
|
"syntaxComment": {
|
||||||
|
"dark": "darkStep11",
|
||||||
|
"light": "lightStep11"
|
||||||
|
},
|
||||||
|
"syntaxKeyword": {
|
||||||
|
"dark": "darkAccent",
|
||||||
|
"light": "lightAccent"
|
||||||
|
},
|
||||||
|
"syntaxFunction": {
|
||||||
|
"dark": "darkStep9",
|
||||||
|
"light": "lightStep9"
|
||||||
|
},
|
||||||
|
"syntaxVariable": {
|
||||||
|
"dark": "darkRed",
|
||||||
|
"light": "lightRed"
|
||||||
|
},
|
||||||
|
"syntaxString": {
|
||||||
|
"dark": "darkGreen",
|
||||||
|
"light": "lightGreen"
|
||||||
|
},
|
||||||
|
"syntaxNumber": {
|
||||||
|
"dark": "darkOrange",
|
||||||
|
"light": "lightOrange"
|
||||||
|
},
|
||||||
|
"syntaxType": {
|
||||||
|
"dark": "darkYellow",
|
||||||
|
"light": "lightYellow"
|
||||||
|
},
|
||||||
|
"syntaxOperator": {
|
||||||
|
"dark": "darkCyan",
|
||||||
|
"light": "lightCyan"
|
||||||
|
},
|
||||||
|
"syntaxPunctuation": {
|
||||||
|
"dark": "darkStep12",
|
||||||
|
"light": "lightStep12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/themes/schema.json
Normal file
59
src/themes/schema.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {},
|
||||||
|
"theme": {
|
||||||
|
"primary": "#000000",
|
||||||
|
"secondary": "#000000",
|
||||||
|
"accent": "#000000",
|
||||||
|
"error": "#000000",
|
||||||
|
"warning": "#000000",
|
||||||
|
"success": "#000000",
|
||||||
|
"info": "#000000",
|
||||||
|
"text": "#000000",
|
||||||
|
"textMuted": "#000000",
|
||||||
|
"selectedListItemText": "#000000",
|
||||||
|
"background": "#000000",
|
||||||
|
"backgroundPanel": "#000000",
|
||||||
|
"backgroundElement": "#000000",
|
||||||
|
"backgroundMenu": "#000000",
|
||||||
|
"border": "#000000",
|
||||||
|
"borderActive": "#000000",
|
||||||
|
"borderSubtle": "#000000",
|
||||||
|
"diffAdded": "#000000",
|
||||||
|
"diffRemoved": "#000000",
|
||||||
|
"diffContext": "#000000",
|
||||||
|
"diffHunkHeader": "#000000",
|
||||||
|
"diffHighlightAdded": "#000000",
|
||||||
|
"diffHighlightRemoved": "#000000",
|
||||||
|
"diffAddedBg": "#000000",
|
||||||
|
"diffRemovedBg": "#000000",
|
||||||
|
"diffContextBg": "#000000",
|
||||||
|
"diffLineNumber": "#000000",
|
||||||
|
"diffAddedLineNumberBg": "#000000",
|
||||||
|
"diffRemovedLineNumberBg": "#000000",
|
||||||
|
"markdownText": "#000000",
|
||||||
|
"markdownHeading": "#000000",
|
||||||
|
"markdownLink": "#000000",
|
||||||
|
"markdownLinkText": "#000000",
|
||||||
|
"markdownCode": "#000000",
|
||||||
|
"markdownBlockQuote": "#000000",
|
||||||
|
"markdownEmph": "#000000",
|
||||||
|
"markdownStrong": "#000000",
|
||||||
|
"markdownHorizontalRule": "#000000",
|
||||||
|
"markdownListItem": "#000000",
|
||||||
|
"markdownListEnumeration": "#000000",
|
||||||
|
"markdownImage": "#000000",
|
||||||
|
"markdownImageText": "#000000",
|
||||||
|
"markdownCodeBlock": "#000000",
|
||||||
|
"syntaxComment": "#000000",
|
||||||
|
"syntaxKeyword": "#000000",
|
||||||
|
"syntaxFunction": "#000000",
|
||||||
|
"syntaxVariable": "#000000",
|
||||||
|
"syntaxString": "#000000",
|
||||||
|
"syntaxNumber": "#000000",
|
||||||
|
"syntaxType": "#000000",
|
||||||
|
"syntaxOperator": "#000000",
|
||||||
|
"syntaxPunctuation": "#000000",
|
||||||
|
"thinkingOpacity": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/themes/tokyo.json
Normal file
74
src/themes/tokyo.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"background": "transparent",
|
||||||
|
"surface": "#1a1b26",
|
||||||
|
"primary": "#7aa2f7",
|
||||||
|
"secondary": "#bb9af7",
|
||||||
|
"accent": "#e0af68",
|
||||||
|
"text": "#c0caf5",
|
||||||
|
"muted": "#565f89",
|
||||||
|
"warning": "#e0af68",
|
||||||
|
"error": "#f7768e",
|
||||||
|
"success": "#9ece6a",
|
||||||
|
"layer0": "transparent",
|
||||||
|
"layer1": "#16161e",
|
||||||
|
"layer2": "#0f0f15",
|
||||||
|
"layer3": "#08080b"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": "primary",
|
||||||
|
"secondary": "secondary",
|
||||||
|
"accent": "accent",
|
||||||
|
"error": "error",
|
||||||
|
"warning": "warning",
|
||||||
|
"success": "success",
|
||||||
|
"info": "secondary",
|
||||||
|
"text": "text",
|
||||||
|
"textMuted": "muted",
|
||||||
|
"selectedListItemText": "background",
|
||||||
|
"background": "background",
|
||||||
|
"backgroundPanel": "surface",
|
||||||
|
"backgroundElement": "layer1",
|
||||||
|
"backgroundMenu": "layer1",
|
||||||
|
"border": "muted",
|
||||||
|
"borderActive": "primary",
|
||||||
|
"borderSubtle": "muted",
|
||||||
|
"diffAdded": "success",
|
||||||
|
"diffRemoved": "error",
|
||||||
|
"diffContext": "muted",
|
||||||
|
"diffHunkHeader": "muted",
|
||||||
|
"diffHighlightAdded": "success",
|
||||||
|
"diffHighlightRemoved": "error",
|
||||||
|
"diffAddedBg": "layer2",
|
||||||
|
"diffRemovedBg": "layer3",
|
||||||
|
"diffContextBg": "layer1",
|
||||||
|
"diffLineNumber": "muted",
|
||||||
|
"diffAddedLineNumberBg": "layer2",
|
||||||
|
"diffRemovedLineNumberBg": "layer3",
|
||||||
|
"markdownText": "text",
|
||||||
|
"markdownHeading": "accent",
|
||||||
|
"markdownLink": "primary",
|
||||||
|
"markdownLinkText": "secondary",
|
||||||
|
"markdownCode": "success",
|
||||||
|
"markdownBlockQuote": "warning",
|
||||||
|
"markdownEmph": "warning",
|
||||||
|
"markdownStrong": "accent",
|
||||||
|
"markdownHorizontalRule": "muted",
|
||||||
|
"markdownListItem": "primary",
|
||||||
|
"markdownListEnumeration": "secondary",
|
||||||
|
"markdownImage": "primary",
|
||||||
|
"markdownImageText": "secondary",
|
||||||
|
"markdownCodeBlock": "text",
|
||||||
|
"syntaxComment": "muted",
|
||||||
|
"syntaxKeyword": "accent",
|
||||||
|
"syntaxFunction": "primary",
|
||||||
|
"syntaxVariable": "secondary",
|
||||||
|
"syntaxString": "success",
|
||||||
|
"syntaxNumber": "warning",
|
||||||
|
"syntaxType": "accent",
|
||||||
|
"syntaxOperator": "secondary",
|
||||||
|
"syntaxPunctuation": "text",
|
||||||
|
"thinkingOpacity": 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
DesktopTheme,
|
DesktopTheme,
|
||||||
ThemeColors,
|
ThemeColors,
|
||||||
|
ThemeDefinition,
|
||||||
ThemeName,
|
ThemeName,
|
||||||
ThemeToken,
|
ThemeToken,
|
||||||
ThemeVariant,
|
ThemeVariant,
|
||||||
} from "../types/settings"
|
} from "../types/settings"
|
||||||
|
import type { ColorValue } from "./theme-schema"
|
||||||
|
|
||||||
// Base theme colors
|
// Base theme colors
|
||||||
export const BASE_THEME_COLORS: ThemeColors = {
|
export const BASE_THEME_COLORS: ThemeColors = {
|
||||||
@@ -63,12 +65,12 @@ export const THEMES_DESKTOP: DesktopTheme = {
|
|||||||
warning: "#fab387",
|
warning: "#fab387",
|
||||||
error: "#f38ba8",
|
error: "#f38ba8",
|
||||||
success: "#a6e3a1",
|
success: "#a6e3a1",
|
||||||
layerBackgrounds: {
|
layerBackgrounds: {
|
||||||
layer0: "transparent",
|
layer0: "transparent",
|
||||||
layer1: "#181825",
|
layer1: "#181825",
|
||||||
layer2: "#11111b",
|
layer2: "#11111b",
|
||||||
layer3: "#0a0a0f",
|
layer3: "#0a0a0f",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,3 +152,9 @@ export function getDefaultTheme(): ThemeVariant {
|
|||||||
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
|
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
|
||||||
)!
|
)!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThemeJsonFile = ThemeDefinition
|
||||||
|
|
||||||
|
export function isColorReference(value: ColorValue): value is string {
|
||||||
|
return typeof value === "string" && !value.startsWith("#")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
|
import type { RGBA } from "@opentui/core"
|
||||||
|
import type { ColorValue, ThemeJson, Variant } from "./theme-schema"
|
||||||
|
|
||||||
export type ThemeName = "system" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom"
|
export type ThemeName = "system" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom"
|
||||||
|
|
||||||
export type LayerBackgrounds = {
|
export type LayerBackgrounds = {
|
||||||
layer0: string
|
layer0: ColorValue
|
||||||
layer1: string
|
layer1: ColorValue
|
||||||
layer2: string
|
layer2: ColorValue
|
||||||
layer3: string
|
layer3: ColorValue
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThemeColors = {
|
export type ThemeColors = {
|
||||||
background: string
|
background: ColorValue
|
||||||
surface: string
|
surface: ColorValue
|
||||||
primary: string
|
primary: ColorValue
|
||||||
secondary: string
|
secondary: ColorValue
|
||||||
accent: string
|
accent: ColorValue
|
||||||
text: string
|
text: ColorValue
|
||||||
muted: string
|
muted: ColorValue
|
||||||
warning: string
|
warning: ColorValue
|
||||||
error: string
|
error: ColorValue
|
||||||
success: string
|
success: ColorValue
|
||||||
layerBackgrounds?: LayerBackgrounds
|
layerBackgrounds?: LayerBackgrounds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +33,10 @@ export type ThemeToken = {
|
|||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResolvedTheme = ThemeColors & {
|
export type ResolvedTheme = Record<string, RGBA> & {
|
||||||
layerBackgrounds: LayerBackgrounds
|
layerBackgrounds: Record<string, RGBA>
|
||||||
|
_hasSelectedListItemText: boolean
|
||||||
|
thinkingOpacity: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DesktopTheme = {
|
export type DesktopTheme = {
|
||||||
@@ -58,3 +63,7 @@ export type AppState = {
|
|||||||
preferences: UserPreferences
|
preferences: UserPreferences
|
||||||
customTheme: ThemeColors
|
customTheme: ThemeColors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThemeMode = "dark" | "light"
|
||||||
|
export type ThemeVariantValue = Variant
|
||||||
|
export type ThemeDefinition = ThemeJson
|
||||||
|
|||||||
26
src/types/theme-schema.ts
Normal file
26
src/types/theme-schema.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
|
export type HexColor = `#${string}`
|
||||||
|
export type RefName = string
|
||||||
|
|
||||||
|
export type Variant = {
|
||||||
|
dark: HexColor | RefName
|
||||||
|
light: HexColor | RefName
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||||
|
|
||||||
|
export type ThemeJson = {
|
||||||
|
$schema?: string
|
||||||
|
defs?: Record<string, HexColor | RefName>
|
||||||
|
theme: Record<string, ColorValue> & {
|
||||||
|
selectedListItemText?: ColorValue
|
||||||
|
backgroundMenu?: ColorValue
|
||||||
|
thinkingOpacity?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeColors = Record<string, RGBA> & {
|
||||||
|
_hasSelectedListItemText: boolean
|
||||||
|
thinkingOpacity: number
|
||||||
|
}
|
||||||
41
src/utils/ansi-to-rgba.ts
Normal file
41
src/utils/ansi-to-rgba.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
|
export function ansiToRgba(code: number) {
|
||||||
|
if (code < 16) {
|
||||||
|
const ansi = [
|
||||||
|
"#000000",
|
||||||
|
"#800000",
|
||||||
|
"#008000",
|
||||||
|
"#808000",
|
||||||
|
"#000080",
|
||||||
|
"#800080",
|
||||||
|
"#008080",
|
||||||
|
"#c0c0c0",
|
||||||
|
"#808080",
|
||||||
|
"#ff0000",
|
||||||
|
"#00ff00",
|
||||||
|
"#ffff00",
|
||||||
|
"#0000ff",
|
||||||
|
"#ff00ff",
|
||||||
|
"#00ffff",
|
||||||
|
"#ffffff",
|
||||||
|
]
|
||||||
|
return RGBA.fromHex(ansi[code] ?? "#000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code < 232) {
|
||||||
|
const index = code - 16
|
||||||
|
const b = index % 6
|
||||||
|
const g = Math.floor(index / 6) % 6
|
||||||
|
const r = Math.floor(index / 36)
|
||||||
|
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
|
||||||
|
return RGBA.fromInts(value(r), value(g), value(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code < 256) {
|
||||||
|
const gray = (code - 232) * 10 + 8
|
||||||
|
return RGBA.fromInts(gray, gray, gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RGBA.fromInts(0, 0, 0)
|
||||||
|
}
|
||||||
67
src/utils/color-generation.ts
Normal file
67
src/utils/color-generation.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
|
export function tint(base: RGBA, overlay: RGBA, alpha: number) {
|
||||||
|
const r = base.r + (overlay.r - base.r) * alpha
|
||||||
|
const g = base.g + (overlay.g - base.g) * alpha
|
||||||
|
const b = base.b + (overlay.b - base.b) * alpha
|
||||||
|
return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateGrayScale(bg: RGBA, isDark: boolean) {
|
||||||
|
const grays: Record<number, RGBA> = {}
|
||||||
|
const bgR = bg.r * 255
|
||||||
|
const bgG = bg.g * 255
|
||||||
|
const bgB = bg.b * 255
|
||||||
|
const luminance = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
|
||||||
|
|
||||||
|
for (let i = 1; i <= 12; i++) {
|
||||||
|
const factor = i / 12.0
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
if (luminance < 10) {
|
||||||
|
const gray = Math.floor(factor * 0.4 * 255)
|
||||||
|
grays[i] = RGBA.fromInts(gray, gray, gray)
|
||||||
|
} else {
|
||||||
|
const newLum = luminance + (255 - luminance) * factor * 0.4
|
||||||
|
const ratio = newLum / luminance
|
||||||
|
grays[i] = RGBA.fromInts(
|
||||||
|
Math.min(bgR * ratio, 255),
|
||||||
|
Math.min(bgG * ratio, 255),
|
||||||
|
Math.min(bgB * ratio, 255)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (luminance > 245) {
|
||||||
|
const gray = Math.floor(255 - factor * 0.4 * 255)
|
||||||
|
grays[i] = RGBA.fromInts(gray, gray, gray)
|
||||||
|
} else {
|
||||||
|
const newLum = luminance * (1 - factor * 0.4)
|
||||||
|
const ratio = newLum / luminance
|
||||||
|
grays[i] = RGBA.fromInts(
|
||||||
|
Math.max(bgR * ratio, 0),
|
||||||
|
Math.max(bgG * ratio, 0),
|
||||||
|
Math.max(bgB * ratio, 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grays
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMutedTextColor(bg: RGBA, isDark: boolean) {
|
||||||
|
const bgR = bg.r * 255
|
||||||
|
const bgG = bg.g * 255
|
||||||
|
const bgB = bg.b * 255
|
||||||
|
const bgLum = 0.299 * bgR + 0.587 * bgG + 0.114 * bgB
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
if (bgLum < 10) return RGBA.fromInts(180, 180, 180)
|
||||||
|
const gray = Math.min(Math.floor(160 + bgLum * 0.3), 200)
|
||||||
|
return RGBA.fromInts(gray, gray, gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bgLum > 245) return RGBA.fromInts(75, 75, 75)
|
||||||
|
const gray = Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
|
||||||
|
return RGBA.fromInts(gray, gray, gray)
|
||||||
|
}
|
||||||
30
src/utils/custom-themes.ts
Normal file
30
src/utils/custom-themes.ts
Normal file
@@ -0,0 +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 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<string, ThemeJson> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
29
src/utils/syntax-highlighter.ts
Normal file
29
src/utils/syntax-highlighter.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||||
|
import { getSyntaxRules } from "./syntax-rules"
|
||||||
|
|
||||||
|
export function generateSyntax(theme: Record<string, RGBA>) {
|
||||||
|
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSubtleSyntax(theme: Record<string, RGBA> & { thinkingOpacity?: number }) {
|
||||||
|
const rules = getSyntaxRules(theme)
|
||||||
|
const opacity = theme.thinkingOpacity ?? 0.6
|
||||||
|
return SyntaxStyle.fromTheme(
|
||||||
|
rules.map((rule) => {
|
||||||
|
if (!rule.style.foreground) return rule
|
||||||
|
const fg = rule.style.foreground
|
||||||
|
return {
|
||||||
|
...rule,
|
||||||
|
style: {
|
||||||
|
...rule.style,
|
||||||
|
foreground: RGBA.fromInts(
|
||||||
|
Math.round(fg.r * 255),
|
||||||
|
Math.round(fg.g * 255),
|
||||||
|
Math.round(fg.b * 255),
|
||||||
|
Math.round(opacity * 255)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/utils/syntax-rules.ts
Normal file
40
src/utils/syntax-rules.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RGBA } from "@opentui/core"
|
||||||
|
|
||||||
|
export type SyntaxRule = {
|
||||||
|
scope: string[]
|
||||||
|
style: {
|
||||||
|
foreground?: RGBA
|
||||||
|
background?: RGBA
|
||||||
|
bold?: boolean
|
||||||
|
italic?: boolean
|
||||||
|
underline?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyntaxRules(theme: Record<string, RGBA>): SyntaxRule[] {
|
||||||
|
return [
|
||||||
|
{ scope: ["default"], style: { foreground: theme.text } },
|
||||||
|
{ scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
|
||||||
|
{ scope: ["string", "symbol", "character.special"], style: { foreground: theme.syntaxString } },
|
||||||
|
{ scope: ["number", "boolean", "constant"], style: { foreground: theme.syntaxNumber } },
|
||||||
|
{ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], style: { foreground: theme.syntaxKeyword, italic: true } },
|
||||||
|
{ scope: ["keyword.type", "type.definition", "class"], style: { foreground: theme.syntaxType, bold: true } },
|
||||||
|
{ scope: ["keyword.function", "function", "function.method"], style: { foreground: theme.syntaxFunction } },
|
||||||
|
{ scope: ["keyword", "keyword.import", "keyword.export"], style: { foreground: theme.syntaxKeyword, italic: true } },
|
||||||
|
{ scope: ["operator", "keyword.operator", "punctuation", "punctuation.delimiter"], style: { foreground: theme.syntaxOperator } },
|
||||||
|
{ scope: ["variable", "variable.parameter", "property"], style: { foreground: theme.syntaxVariable } },
|
||||||
|
{ scope: ["type", "module", "namespace"], style: { foreground: theme.syntaxType } },
|
||||||
|
{ scope: ["punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } },
|
||||||
|
{ scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: theme.markdownHeading, bold: true } },
|
||||||
|
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
|
||||||
|
{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
|
||||||
|
{ scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
|
||||||
|
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
||||||
|
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
|
||||||
|
{ scope: ["markup.link", "markup.link.url", "string.special.url"], style: { foreground: theme.markdownLink, underline: true } },
|
||||||
|
{ scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
|
||||||
|
{ scope: ["diff.plus"], style: { foreground: theme.diffAdded, background: theme.diffAddedBg } },
|
||||||
|
{ scope: ["diff.minus"], style: { foreground: theme.diffRemoved, background: theme.diffRemovedBg } },
|
||||||
|
{ scope: ["diff.delta"], style: { foreground: theme.diffContext, background: theme.diffContextBg } },
|
||||||
|
]
|
||||||
|
}
|
||||||
110
src/utils/system-theme.ts
Normal file
110
src/utils/system-theme.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { RGBA, type TerminalColors } from "@opentui/core"
|
||||||
|
import { ansiToRgba } from "./ansi-to-rgba"
|
||||||
|
import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation"
|
||||||
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
|
|
||||||
|
let cached: TerminalColors | null = null
|
||||||
|
|
||||||
|
export function clearPaletteCache() {
|
||||||
|
cached = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectSystemTheme(colors: TerminalColors) {
|
||||||
|
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000")
|
||||||
|
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b
|
||||||
|
const mode = luminance > 0.5 ? "light" : "dark"
|
||||||
|
return { mode, background: bg }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||||
|
cached = colors
|
||||||
|
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000")
|
||||||
|
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7] ?? "#ffffff")
|
||||||
|
const transparent = RGBA.fromInts(0, 0, 0, 0)
|
||||||
|
const isDark = mode === "dark"
|
||||||
|
|
||||||
|
const col = (i: number) => {
|
||||||
|
const value = colors.palette[i]
|
||||||
|
if (value) return RGBA.fromHex(value)
|
||||||
|
return ansiToRgba(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const grays = generateGrayScale(bg, isDark)
|
||||||
|
const textMuted = generateMutedTextColor(bg, isDark)
|
||||||
|
|
||||||
|
const ansi = {
|
||||||
|
black: col(0),
|
||||||
|
red: col(1),
|
||||||
|
green: col(2),
|
||||||
|
yellow: col(3),
|
||||||
|
blue: col(4),
|
||||||
|
magenta: col(5),
|
||||||
|
cyan: col(6),
|
||||||
|
white: col(7),
|
||||||
|
redBright: col(9),
|
||||||
|
greenBright: col(10),
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffAlpha = isDark ? 0.22 : 0.14
|
||||||
|
const diffAddedBg = tint(bg, ansi.green, diffAlpha)
|
||||||
|
const diffRemovedBg = tint(bg, ansi.red, diffAlpha)
|
||||||
|
const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha)
|
||||||
|
const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha)
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: {
|
||||||
|
primary: ansi.cyan,
|
||||||
|
secondary: ansi.magenta,
|
||||||
|
accent: ansi.cyan,
|
||||||
|
error: ansi.red,
|
||||||
|
warning: ansi.yellow,
|
||||||
|
success: ansi.green,
|
||||||
|
info: ansi.cyan,
|
||||||
|
text: fg,
|
||||||
|
textMuted,
|
||||||
|
selectedListItemText: bg,
|
||||||
|
background: transparent,
|
||||||
|
backgroundPanel: grays[2],
|
||||||
|
backgroundElement: grays[3],
|
||||||
|
backgroundMenu: grays[3],
|
||||||
|
borderSubtle: grays[6],
|
||||||
|
border: grays[7],
|
||||||
|
borderActive: grays[8],
|
||||||
|
diffAdded: ansi.green,
|
||||||
|
diffRemoved: ansi.red,
|
||||||
|
diffContext: grays[7],
|
||||||
|
diffHunkHeader: grays[7],
|
||||||
|
diffHighlightAdded: ansi.greenBright,
|
||||||
|
diffHighlightRemoved: ansi.redBright,
|
||||||
|
diffAddedBg,
|
||||||
|
diffRemovedBg,
|
||||||
|
diffContextBg: grays[1],
|
||||||
|
diffLineNumber: grays[6],
|
||||||
|
diffAddedLineNumberBg,
|
||||||
|
diffRemovedLineNumberBg,
|
||||||
|
markdownText: fg,
|
||||||
|
markdownHeading: fg,
|
||||||
|
markdownLink: ansi.blue,
|
||||||
|
markdownLinkText: ansi.cyan,
|
||||||
|
markdownCode: ansi.green,
|
||||||
|
markdownBlockQuote: ansi.yellow,
|
||||||
|
markdownEmph: ansi.yellow,
|
||||||
|
markdownStrong: fg,
|
||||||
|
markdownHorizontalRule: grays[7],
|
||||||
|
markdownListItem: ansi.blue,
|
||||||
|
markdownListEnumeration: ansi.cyan,
|
||||||
|
markdownImage: ansi.blue,
|
||||||
|
markdownImageText: ansi.cyan,
|
||||||
|
markdownCodeBlock: fg,
|
||||||
|
syntaxComment: textMuted,
|
||||||
|
syntaxKeyword: ansi.magenta,
|
||||||
|
syntaxFunction: ansi.blue,
|
||||||
|
syntaxVariable: fg,
|
||||||
|
syntaxString: ansi.green,
|
||||||
|
syntaxNumber: ansi.yellow,
|
||||||
|
syntaxType: ansi.cyan,
|
||||||
|
syntaxOperator: ansi.cyan,
|
||||||
|
syntaxPunctuation: fg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/utils/theme-css.ts
Normal file
36
src/utils/theme-css.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { RGBA } from "@opentui/core"
|
||||||
|
import type { ColorValue } from "../types/theme-schema"
|
||||||
|
|
||||||
|
const toCss = (value: ColorValue | RGBA) => {
|
||||||
|
if (value instanceof RGBA) {
|
||||||
|
const r = Math.round(value.r * 255)
|
||||||
|
const g = Math.round(value.g * 255)
|
||||||
|
const b = Math.round(value.b * 255)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${value.a})`
|
||||||
|
}
|
||||||
|
if (typeof value === "number") return `var(--ansi-${value})`
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return value.dark
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
|
||||||
|
const root = document.documentElement
|
||||||
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
|
if (key === "layerBackgrounds" && typeof value === "object") {
|
||||||
|
const layers = value as Record<string, RGBA | ColorValue>
|
||||||
|
for (const [layer, color] of Object.entries(layers)) {
|
||||||
|
root.style.setProperty(`--color-${layer}`, toCss(color))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemeAttribute(themeName: string) {
|
||||||
|
document.documentElement.setAttribute("data-theme", themeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveColorReference(value: ColorValue) {
|
||||||
|
return toCss(value)
|
||||||
|
}
|
||||||
49
src/utils/theme-loader.ts
Normal file
49
src/utils/theme-loader.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import path from "path"
|
||||||
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
|
import { THEME_JSON } from "../constants/themes"
|
||||||
|
|
||||||
|
export async function loadTheme(name: string) {
|
||||||
|
if (THEME_JSON[name]) return THEME_JSON[name]
|
||||||
|
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
|
||||||
|
return loadThemeFromPath(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadThemeFromPath(file: string) {
|
||||||
|
const json = (await Bun.file(file).json()) as ThemeJson
|
||||||
|
validateTheme(json, file)
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllThemes() {
|
||||||
|
return { ...THEME_JSON, ...(await getCustomThemes()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 result: Record<string, ThemeJson> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTheme(theme: ThemeJson, source?: string) {
|
||||||
|
if (!theme || typeof theme !== "object") {
|
||||||
|
throw new Error(`Invalid theme${source ? ` (${source})` : ""}`)
|
||||||
|
}
|
||||||
|
if (!theme.theme || typeof theme.theme !== "object") {
|
||||||
|
throw new Error(`Theme missing 'theme' object${source ? ` (${source})` : ""}`)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
62
src/utils/theme-resolver.ts
Normal file
62
src/utils/theme-resolver.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { RGBA } from "@opentui/core"
|
||||||
|
import type { ColorValue, ThemeJson } from "../types/theme-schema"
|
||||||
|
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 {
|
||||||
|
if (value instanceof RGBA) return value
|
||||||
|
if (typeof value === "number") return ansiToRgba(value)
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value === "transparent" || value === "none") return RGBA.fromInts(0, 0, 0, 0)
|
||||||
|
if (value.startsWith("#")) return RGBA.fromHex(value)
|
||||||
|
if (defs[value] != null) return resolveColor(defs[value])
|
||||||
|
const ref = theme.theme[value]
|
||||||
|
if (ref != null) return resolveColor(ref)
|
||||||
|
throw new Error(`Color reference "${value}" not found in defs or theme`)
|
||||||
|
}
|
||||||
|
return resolveColor(value[mode])
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = Object.fromEntries(
|
||||||
|
Object.entries(theme.theme)
|
||||||
|
.filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu" && key !== "thinkingOpacity")
|
||||||
|
.map(([key, value]) => [key, resolveColor(value)])
|
||||||
|
) as Record<string, RGBA>
|
||||||
|
|
||||||
|
const hasSelected = theme.theme.selectedListItemText !== undefined
|
||||||
|
resolved.selectedListItemText = hasSelected
|
||||||
|
? resolveColor(theme.theme.selectedListItemText!)
|
||||||
|
: resolved.background
|
||||||
|
|
||||||
|
resolved.backgroundMenu = theme.theme.backgroundMenu
|
||||||
|
? resolveColor(theme.theme.backgroundMenu)
|
||||||
|
: resolved.backgroundElement
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/utils/theme.test.ts
Normal file
71
src/utils/theme.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { ansiToRgba } from "./ansi-to-rgba"
|
||||||
|
import { resolveTheme } from "./theme-resolver"
|
||||||
|
import type { ThemeJson } from "../types/theme-schema"
|
||||||
|
|
||||||
|
describe("theme utils", () => {
|
||||||
|
it("converts ansi codes", () => {
|
||||||
|
const color = ansiToRgba(1)
|
||||||
|
expect(color).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resolves simple theme", () => {
|
||||||
|
const json: ThemeJson = {
|
||||||
|
theme: {
|
||||||
|
primary: "#ffffff",
|
||||||
|
secondary: "#000000",
|
||||||
|
accent: "#000000",
|
||||||
|
error: "#000000",
|
||||||
|
warning: "#000000",
|
||||||
|
success: "#000000",
|
||||||
|
info: "#000000",
|
||||||
|
text: "#000000",
|
||||||
|
textMuted: "#000000",
|
||||||
|
background: "#000000",
|
||||||
|
backgroundPanel: "#000000",
|
||||||
|
backgroundElement: "#000000",
|
||||||
|
border: "#000000",
|
||||||
|
borderActive: "#000000",
|
||||||
|
borderSubtle: "#000000",
|
||||||
|
diffAdded: "#000000",
|
||||||
|
diffRemoved: "#000000",
|
||||||
|
diffContext: "#000000",
|
||||||
|
diffHunkHeader: "#000000",
|
||||||
|
diffHighlightAdded: "#000000",
|
||||||
|
diffHighlightRemoved: "#000000",
|
||||||
|
diffAddedBg: "#000000",
|
||||||
|
diffRemovedBg: "#000000",
|
||||||
|
diffContextBg: "#000000",
|
||||||
|
diffLineNumber: "#000000",
|
||||||
|
diffAddedLineNumberBg: "#000000",
|
||||||
|
diffRemovedLineNumberBg: "#000000",
|
||||||
|
markdownText: "#000000",
|
||||||
|
markdownHeading: "#000000",
|
||||||
|
markdownLink: "#000000",
|
||||||
|
markdownLinkText: "#000000",
|
||||||
|
markdownCode: "#000000",
|
||||||
|
markdownBlockQuote: "#000000",
|
||||||
|
markdownEmph: "#000000",
|
||||||
|
markdownStrong: "#000000",
|
||||||
|
markdownHorizontalRule: "#000000",
|
||||||
|
markdownListItem: "#000000",
|
||||||
|
markdownListEnumeration: "#000000",
|
||||||
|
markdownImage: "#000000",
|
||||||
|
markdownImageText: "#000000",
|
||||||
|
markdownCodeBlock: "#000000",
|
||||||
|
syntaxComment: "#000000",
|
||||||
|
syntaxKeyword: "#000000",
|
||||||
|
syntaxFunction: "#000000",
|
||||||
|
syntaxVariable: "#000000",
|
||||||
|
syntaxString: "#000000",
|
||||||
|
syntaxNumber: "#000000",
|
||||||
|
syntaxType: "#000000",
|
||||||
|
syntaxOperator: "#000000",
|
||||||
|
syntaxPunctuation: "#000000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveTheme(json, "dark") as unknown as { primary: unknown }
|
||||||
|
expect(resolved.primary).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,44 +3,46 @@
|
|||||||
* Handles dynamic theme switching by updating CSS custom properties
|
* Handles dynamic theme switching by updating CSS custom properties
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function applyTheme(theme: {
|
import { RGBA, type TerminalColors } from "@opentui/core"
|
||||||
background: string
|
import type { ThemeColors } from "../types/settings"
|
||||||
surface: string
|
import type { ColorValue, ThemeJson } from "../types/theme-schema"
|
||||||
primary: string
|
import { THEME_JSON } from "../constants/themes"
|
||||||
secondary: string
|
import { getCustomThemes } from "./custom-themes"
|
||||||
accent: string
|
import { resolveTheme as resolveThemeJson } from "./theme-resolver"
|
||||||
text: string
|
import { generateSystemTheme } from "./system-theme"
|
||||||
muted: string
|
|
||||||
warning: string
|
const toCss = (value: ColorValue | RGBA) => {
|
||||||
error: string
|
if (value instanceof RGBA) {
|
||||||
success: string
|
const r = Math.round(value.r * 255)
|
||||||
layerBackgrounds?: {
|
const g = Math.round(value.g * 255)
|
||||||
layer0: string
|
const b = Math.round(value.b * 255)
|
||||||
layer1: string
|
return `rgba(${r}, ${g}, ${b}, ${value.a})`
|
||||||
layer2: string
|
|
||||||
layer3: string
|
|
||||||
}
|
}
|
||||||
}) {
|
if (typeof value === "number") return `var(--ansi-${value})`
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return value.dark
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
root.style.setProperty("--color-background", toCss(theme.background as ColorValue))
|
||||||
|
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue))
|
||||||
|
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue))
|
||||||
|
root.style.setProperty("--color-secondary", toCss(theme.secondary as ColorValue))
|
||||||
|
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue))
|
||||||
|
root.style.setProperty("--color-text", toCss(theme.text as ColorValue))
|
||||||
|
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue))
|
||||||
|
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue))
|
||||||
|
root.style.setProperty("--color-error", toCss(theme.error as ColorValue))
|
||||||
|
root.style.setProperty("--color-success", toCss(theme.success as ColorValue))
|
||||||
|
|
||||||
// Apply base theme colors
|
const layers = theme.layerBackgrounds as Record<string, ColorValue> | undefined
|
||||||
root.style.setProperty("--color-background", theme.background)
|
if (layers) {
|
||||||
root.style.setProperty("--color-surface", theme.surface)
|
root.style.setProperty("--color-layer0", toCss(layers.layer0))
|
||||||
root.style.setProperty("--color-primary", theme.primary)
|
root.style.setProperty("--color-layer1", toCss(layers.layer1))
|
||||||
root.style.setProperty("--color-secondary", theme.secondary)
|
root.style.setProperty("--color-layer2", toCss(layers.layer2))
|
||||||
root.style.setProperty("--color-accent", theme.accent)
|
root.style.setProperty("--color-layer3", toCss(layers.layer3))
|
||||||
root.style.setProperty("--color-text", theme.text)
|
|
||||||
root.style.setProperty("--color-muted", theme.muted)
|
|
||||||
root.style.setProperty("--color-warning", theme.warning)
|
|
||||||
root.style.setProperty("--color-error", theme.error)
|
|
||||||
root.style.setProperty("--color-success", theme.success)
|
|
||||||
|
|
||||||
// Apply layer backgrounds if available
|
|
||||||
if (theme.layerBackgrounds) {
|
|
||||||
root.style.setProperty("--color-layer0", theme.layerBackgrounds.layer0)
|
|
||||||
root.style.setProperty("--color-layer1", theme.layerBackgrounds.layer1)
|
|
||||||
root.style.setProperty("--color-layer2", theme.layerBackgrounds.layer2)
|
|
||||||
root.style.setProperty("--color-layer3", theme.layerBackgrounds.layer3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,36 @@ export function getSystemThemeMode(): "dark" | "light" {
|
|||||||
* Apply CSS variable data-theme attribute
|
* Apply CSS variable data-theme attribute
|
||||||
*/
|
*/
|
||||||
export function setThemeAttribute(themeName: string) {
|
export function setThemeAttribute(themeName: string) {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
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)
|
||||||
|
}
|
||||||
|
|||||||
63
tasks/subtasks/theme-refactoring-01-create-schema.md
Normal file
63
tasks/subtasks/theme-refactoring-01-create-schema.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 01. Create JSON Theme Schema and File Structure
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-01
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, infrastructure]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create the JSON theme schema and establish the file structure for theme definitions
|
||||||
|
- Define the theme.json schema that opencode uses
|
||||||
|
- Create the directory structure for JSON theme files
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/types/theme-schema.ts` - JSON theme schema definition
|
||||||
|
- `src/themes/*.json` - Directory for theme JSON files
|
||||||
|
- `src/themes/schema.json` - Theme schema reference
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 1.1: Create `src/types/theme-schema.ts` with TypeScript interfaces matching the opencode theme JSON structure
|
||||||
|
- Define `ThemeJson` interface with `$schema`, `defs`, and `theme` properties
|
||||||
|
- Define `ColorValue` type supporting hex colors, color references, variants, and RGBA
|
||||||
|
- Define `Variant` type for light/dark mode color definitions
|
||||||
|
- Export interfaces for type checking
|
||||||
|
|
||||||
|
- Step 1.2: Create `src/themes/schema.json` with the opencode theme schema reference
|
||||||
|
- Add `$schema: "https://opencode.ai/theme.json"`
|
||||||
|
- Document the theme structure in comments
|
||||||
|
|
||||||
|
- Step 1.3: Create `src/themes/` directory
|
||||||
|
- Ensure directory exists for JSON theme files
|
||||||
|
|
||||||
|
- Step 1.4: Create a sample theme file in `src/themes/opencode.json`
|
||||||
|
- Use the opencode theme as reference
|
||||||
|
- Include proper `$schema` reference
|
||||||
|
- Define `defs` with all color references
|
||||||
|
- Define `theme` with semantic color mappings
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `ThemeJson` type definition matches opencode structure
|
||||||
|
- Test `ColorValue` type accepts hex colors, references, variants, and RGBA
|
||||||
|
- Test `Variant` type structure is correct
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Verify JSON file can be parsed without errors
|
||||||
|
- Validate schema reference is correct
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/types/theme-schema.ts` file exists with all required interfaces
|
||||||
|
- `src/themes/schema.json` contains valid schema reference
|
||||||
|
- `src/themes/` directory is created
|
||||||
|
- `src/themes/opencode.json` can be imported and parsed successfully
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass with no type errors
|
||||||
|
- Run: `cat src/themes/opencode.json | jq .` - Should be valid JSON
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Follow opencode's theme.json structure exactly
|
||||||
|
- Use TypeScript interfaces to ensure type safety
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme/`
|
||||||
77
tasks/subtasks/theme-refactoring-02-convert-themes.md
Normal file
77
tasks/subtasks/theme-refactoring-02-convert-themes.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 02. Convert Existing Themes to JSON Format
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-02
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: [theme-refactoring-01]
|
||||||
|
tags: [implementation, migration]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Convert all existing PodTui themes (catppuccin, gruvbox, tokyo, nord) from TypeScript constants to JSON format
|
||||||
|
- Ensure each theme matches the opencode JSON structure
|
||||||
|
- Maintain color fidelity with original theme definitions
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/themes/catppuccin.json` - Catppuccin theme in JSON format
|
||||||
|
- `src/themes/gruvbox.json` - Gruvbox theme in JSON format
|
||||||
|
- `src/themes/tokyo.json` - Tokyo Night theme in JSON format
|
||||||
|
- `src/themes/nord.json` - Nord theme in JSON format
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 2.1: Convert `catppuccin` theme from `src/types/desktop-theme.ts` to JSON
|
||||||
|
- Extract all color definitions from `THEMES_DESKTOP.variants.find((v) => v.name === "catppuccin")!.colors`
|
||||||
|
- Create `defs` object with all color references (dark and light variants)
|
||||||
|
- Create `theme` object with semantic mappings
|
||||||
|
- Add `$schema` reference
|
||||||
|
- Verify color values match original definitions
|
||||||
|
|
||||||
|
- Step 2.2: Convert `gruvbox` theme from `src/types/desktop-theme.ts` to JSON
|
||||||
|
- Extract all color definitions from `THEMES_DESKTOP.variants.find((v) => v.name === "gruvbox")!.colors`
|
||||||
|
- Create `defs` object with all color references
|
||||||
|
- Create `theme` object with semantic mappings
|
||||||
|
- Add `$schema` reference
|
||||||
|
|
||||||
|
- Step 2.3: Convert `tokyo` theme from `src/types/desktop-theme.ts` to JSON
|
||||||
|
- Extract all color definitions from `THEMES_DESKTOP.variants.find((v) => v.name === "tokyo")!.colors`
|
||||||
|
- Create `defs` object with all color references
|
||||||
|
- Create `theme` object with semantic mappings
|
||||||
|
- Add `$schema` reference
|
||||||
|
|
||||||
|
- Step 2.4: Convert `nord` theme from `src/types/desktop-theme.ts` to JSON
|
||||||
|
- Extract all color definitions from `THEMES_DESKTOP.variants.find((v) => v.name === "nord")!.colors`
|
||||||
|
- Create `defs` object with all color references
|
||||||
|
- Create `theme` object with semantic mappings
|
||||||
|
- Add `$schema` reference
|
||||||
|
|
||||||
|
- Step 2.5: Verify all JSON files are valid
|
||||||
|
- Check syntax with `bun run typecheck`
|
||||||
|
- Ensure all imports work correctly
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test each JSON file can be imported without errors
|
||||||
|
- Verify color values match original TypeScript definitions
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Load each theme and verify all colors are present
|
||||||
|
- Check that theme structure matches expected schema
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All four theme JSON files exist in `src/themes/`
|
||||||
|
- Each JSON file follows the theme schema
|
||||||
|
- Color values match original theme definitions exactly
|
||||||
|
- All JSON files are valid and can be parsed
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `cat src/themes/catppuccin.json | jq .` - Should be valid JSON
|
||||||
|
- Run: `cat src/themes/gruvbox.json | jq .` - Should be valid JSON
|
||||||
|
- Run: `cat src/themes/tokyo.json | jq .` - Should be valid JSON
|
||||||
|
- Run: `cat src/themes/nord.json | jq .` - Should be valid JSON
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's theme structure as reference
|
||||||
|
- Maintain backward compatibility with existing color definitions
|
||||||
|
- Ensure both dark and light variants are included if available
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme/`
|
||||||
70
tasks/subtasks/theme-refactoring-03-update-types.md
Normal file
70
tasks/subtasks/theme-refactoring-03-update-types.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 03. Update Type Definitions for Color References and Variants
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-03
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: [theme-refactoring-01]
|
||||||
|
tags: [implementation, types]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Update type definitions to support the new JSON theme structure
|
||||||
|
- Add support for color references, variants, and light/dark mode
|
||||||
|
- Maintain backward compatibility with existing code
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/types/theme-schema.ts` - Updated with new types
|
||||||
|
- `src/types/settings.ts` - Updated with color reference types
|
||||||
|
- `src/types/desktop-theme.ts` - Updated to support JSON themes
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 3.1: Update `src/types/theme-schema.ts`
|
||||||
|
- Export `ThemeJson` interface
|
||||||
|
- Export `ColorValue` type
|
||||||
|
- Export `Variant` type
|
||||||
|
- Add `ThemeColors` type for resolved theme colors
|
||||||
|
|
||||||
|
- Step 3.2: Update `src/types/settings.ts`
|
||||||
|
- Add `ThemeJson` import
|
||||||
|
- Add `ColorValue` type definition
|
||||||
|
- Add `Variant` type definition
|
||||||
|
- Update `ThemeColors` to support color references
|
||||||
|
- Add `ThemeJson` type for JSON theme files
|
||||||
|
|
||||||
|
- Step 3.3: Update `src/types/desktop-theme.ts`
|
||||||
|
- Add imports for `ThemeJson`, `ColorValue`, `Variant`
|
||||||
|
- Add `ThemeJson` type for JSON theme files
|
||||||
|
- Update existing types to support color references
|
||||||
|
- Add helper functions for JSON theme loading
|
||||||
|
|
||||||
|
- Step 3.4: Ensure backward compatibility
|
||||||
|
- Keep existing `ThemeColors` structure for resolved themes
|
||||||
|
- Ensure existing code can still use theme colors as strings
|
||||||
|
- Add type guards for color references
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `ThemeJson` type accepts valid JSON theme structure
|
||||||
|
- Test `ColorValue` type accepts hex colors, references, variants, and RGBA
|
||||||
|
- Test `Variant` type structure is correct
|
||||||
|
- Test existing `ThemeColors` type remains compatible
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Verify type imports work correctly
|
||||||
|
- Test type inference with JSON theme files
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All type definitions are updated and exported
|
||||||
|
- Backward compatibility maintained with existing code
|
||||||
|
- New types support color references and variants
|
||||||
|
- Type checking passes without errors
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass with no errors
|
||||||
|
- Verify existing components can still use theme colors
|
||||||
|
- Test type inference with new theme JSON files
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use TypeScript's `with { type: "json" }` for JSON imports
|
||||||
|
- Ensure all types are properly exported for use across the codebase
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx`
|
||||||
83
tasks/subtasks/theme-refactoring-04-theme-resolution.md
Normal file
83
tasks/subtasks/theme-refactoring-04-theme-resolution.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 04. Create Theme Resolution Logic with Color Reference Lookup
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-04
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: [theme-refactoring-02, theme-refactoring-03]
|
||||||
|
tags: [implementation, logic]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement theme resolution logic that handles color references and variants
|
||||||
|
- Create function to resolve colors from theme JSON files
|
||||||
|
- Support hex colors, color references, ANSI codes, and RGBA values
|
||||||
|
- Handle light/dark mode selection based on current mode
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/theme-resolver.ts` - Theme resolution utility
|
||||||
|
- `src/utils/ansi-to-rgba.ts` - ANSI color conversion utility
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 4.1: Create `src/utils/ansi-to-rgba.ts`
|
||||||
|
- Implement `ansiToRgba(code: number): RGBA` function
|
||||||
|
- Handle standard ANSI colors (0-15)
|
||||||
|
- Handle 6x6x6 color cube (16-231)
|
||||||
|
- Handle grayscale ramp (232-255)
|
||||||
|
- Add type definitions for RGBA
|
||||||
|
|
||||||
|
- Step 4.2: Create `src/utils/theme-resolver.ts`
|
||||||
|
- Implement `resolveTheme(theme: ThemeJson, mode: "dark" | "light"): ThemeColors`
|
||||||
|
- Create `resolveColor(c: ColorValue): RGBA` helper function
|
||||||
|
- Handle RGBA objects directly
|
||||||
|
- Handle hex color strings
|
||||||
|
- Handle color references from `defs`
|
||||||
|
- Handle variants (dark/light mode)
|
||||||
|
- Handle ANSI codes
|
||||||
|
- Add error handling for invalid color references
|
||||||
|
|
||||||
|
- Step 4.3: Implement color reference resolution
|
||||||
|
- Create lookup logic for `defs` object
|
||||||
|
- Add fallback to theme colors if reference not in defs
|
||||||
|
- Throw descriptive errors for invalid references
|
||||||
|
|
||||||
|
- Step 4.4: Handle optional theme properties
|
||||||
|
- Support `selectedListItemText` property
|
||||||
|
- Support `backgroundMenu` property
|
||||||
|
- Support `thinkingOpacity` property
|
||||||
|
- Add default values for missing properties
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `ansiToRgba` with ANSI codes 0-15
|
||||||
|
- Test `ansiToRgba` with color cube codes 16-231
|
||||||
|
- Test `ansiToRgba` with grayscale codes 232-255
|
||||||
|
- Test `resolveColor` with hex colors
|
||||||
|
- Test `resolveColor` with color references
|
||||||
|
- Test `resolveColor` with light/dark variants
|
||||||
|
- Test `resolveColor` with RGBA objects
|
||||||
|
- Test `resolveTheme` with complete theme JSON
|
||||||
|
- Test `resolveTheme` with missing optional properties
|
||||||
|
- Test error handling for invalid color references
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test resolving colors from actual theme JSON files
|
||||||
|
- Verify light/dark mode selection works correctly
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/utils/ansi-to-rgba.ts` file exists with conversion functions
|
||||||
|
- `src/utils/theme-resolver.ts` file exists with resolution logic
|
||||||
|
- All color value types are handled correctly
|
||||||
|
- Error messages are descriptive and helpful
|
||||||
|
- Theme resolution works with both light and dark modes
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run unit tests with `bun test src/utils/theme-resolver.test.ts`
|
||||||
|
- Test with sample theme JSON files
|
||||||
|
- Verify error messages are clear
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's implementation as reference for color resolution
|
||||||
|
- Ensure RGBA values are normalized to 0-1 range
|
||||||
|
- Add comprehensive error handling for invalid inputs
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 176-277)
|
||||||
80
tasks/subtasks/theme-refactoring-05-theme-context.md
Normal file
80
tasks/subtasks/theme-refactoring-05-theme-context.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 05. Migrate ThemeContext to SolidJS Pattern
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-05
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: [theme-refactoring-04]
|
||||||
|
tags: [implementation, context]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Migrate ThemeContext from basic React context to SolidJS context pattern
|
||||||
|
- Implement reactive state management for theme switching
|
||||||
|
- Add persistence for theme selection and mode
|
||||||
|
- Support system theme detection
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/context/ThemeContext.tsx` - Updated with SolidJS pattern
|
||||||
|
- `src/utils/theme-context.ts` - Theme context utilities
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 5.1: Update `src/context/ThemeContext.tsx`
|
||||||
|
- Import SolidJS hooks: `createSignal`, `createEffect`, `createMemo`, `createStore`
|
||||||
|
- Replace React context with SolidJS `createSimpleContext` pattern (from opencode)
|
||||||
|
- Add theme loading logic
|
||||||
|
- Implement reactive theme state
|
||||||
|
- Add system theme detection
|
||||||
|
- Support theme persistence via localStorage
|
||||||
|
|
||||||
|
- Step 5.2: Implement theme state management
|
||||||
|
- Create store with `themes`, `mode`, `active`, `ready`
|
||||||
|
- Initialize with default theme (opencode)
|
||||||
|
- Load custom themes from JSON files
|
||||||
|
- Handle system theme detection
|
||||||
|
|
||||||
|
- Step 5.3: Add reactive theme resolution
|
||||||
|
- Create `createMemo` for resolved theme colors
|
||||||
|
- Create `createMemo` for syntax highlighting
|
||||||
|
- Create `createMemo` for subtle syntax
|
||||||
|
|
||||||
|
- Step 5.4: Implement theme switching
|
||||||
|
- Add `setTheme(theme: string)` method
|
||||||
|
- Add `setMode(mode: "dark" | "light")` method
|
||||||
|
- Persist theme and mode to localStorage
|
||||||
|
|
||||||
|
- Step 5.5: Add system theme detection
|
||||||
|
- Detect terminal background and foreground colors
|
||||||
|
- Generate system theme based on terminal palette
|
||||||
|
- Handle system theme preference changes
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test theme state initialization
|
||||||
|
- Test theme switching logic
|
||||||
|
- Test mode switching logic
|
||||||
|
- Test system theme detection
|
||||||
|
- Test localStorage persistence
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test theme context in component tree
|
||||||
|
- Test reactive theme updates
|
||||||
|
- Test system theme changes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/context/ThemeContext.tsx` uses SolidJS pattern
|
||||||
|
- Theme context provides reactive theme state
|
||||||
|
- Theme switching works correctly
|
||||||
|
- System theme detection is functional
|
||||||
|
- Theme and mode are persisted to localStorage
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `bun test src/context/ThemeContext.test.ts`
|
||||||
|
- Test theme switching in application
|
||||||
|
- Test system theme detection
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's ThemeProvider pattern as reference
|
||||||
|
- Follow SolidJS best practices for reactive state
|
||||||
|
- Ensure proper cleanup of effects and listeners
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 279-392)
|
||||||
79
tasks/subtasks/theme-refactoring-06-theme-loader.md
Normal file
79
tasks/subtasks/theme-refactoring-06-theme-loader.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 06. Add Theme Loader for JSON Files and Custom Themes
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-06
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P1
|
||||||
|
depends_on: [theme-refactoring-02, theme-refactoring-04]
|
||||||
|
tags: [implementation, loading]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Create theme loader to load JSON theme files
|
||||||
|
- Support loading custom themes from multiple directories
|
||||||
|
- Provide API for theme discovery and loading
|
||||||
|
- Handle theme file validation
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/theme-loader.ts` - Theme loader utilities
|
||||||
|
- `src/utils/custom-themes.ts` - Custom theme loading logic
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 6.1: Create `src/utils/theme-loader.ts`
|
||||||
|
- Implement `loadTheme(name: string): Promise<ThemeJson>`
|
||||||
|
- Implement `loadThemeFromPath(path: string): Promise<ThemeJson>`
|
||||||
|
- Implement `getAllThemes(): Promise<Record<string, ThemeJson>>`
|
||||||
|
- Add error handling for missing or invalid theme files
|
||||||
|
|
||||||
|
- Step 6.2: Create `src/utils/custom-themes.ts`
|
||||||
|
- Implement `getCustomThemes()` function
|
||||||
|
- Scan for theme files in multiple directories:
|
||||||
|
- `~/.config/podtui/themes/`
|
||||||
|
- `./.podtui/themes/`
|
||||||
|
- Project root `./themes/`
|
||||||
|
- Support custom theme files with `.json` extension
|
||||||
|
- Return merged theme registry
|
||||||
|
|
||||||
|
- Step 6.3: Add theme file validation
|
||||||
|
- Validate theme JSON structure
|
||||||
|
- Check required properties (`defs`, `theme`)
|
||||||
|
- Validate color references in `defs`
|
||||||
|
- Add warning for optional properties
|
||||||
|
|
||||||
|
- Step 6.4: Implement theme discovery
|
||||||
|
- List all available theme files
|
||||||
|
- Provide theme metadata (name, description)
|
||||||
|
- Support theme aliases (e.g., "catppuccin" -> "catppuccin.json")
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `loadTheme` with existing theme files
|
||||||
|
- Test `loadTheme` with missing theme files
|
||||||
|
- Test `loadThemeFromPath` with custom paths
|
||||||
|
- Test `getAllThemes` returns all available themes
|
||||||
|
- Test `getCustomThemes` scans multiple directories
|
||||||
|
- Test theme file validation
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test loading all available themes
|
||||||
|
- Test custom theme loading from directories
|
||||||
|
- Verify theme discovery works correctly
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/utils/theme-loader.ts` file exists with loading functions
|
||||||
|
- `src/utils/custom-themes.ts` file exists with custom theme logic
|
||||||
|
- Custom themes can be loaded from multiple directories
|
||||||
|
- Theme validation prevents invalid files
|
||||||
|
- Theme discovery API is functional
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `bun test src/utils/theme-loader.test.ts`
|
||||||
|
- Run: `bun test src/utils/custom-themes.test.ts`
|
||||||
|
- Test loading all available themes manually
|
||||||
|
- Test custom theme loading from directories
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's `getCustomThemes` pattern as reference
|
||||||
|
- Support both local and global theme directories
|
||||||
|
- Add comprehensive error messages for invalid theme files
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 394-419)
|
||||||
83
tasks/subtasks/theme-refactoring-07-system-theme.md
Normal file
83
tasks/subtasks/theme-refactoring-07-system-theme.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 07. Implement System Theme Detection with Terminal Palette
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-07
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P1
|
||||||
|
depends_on: [theme-refactoring-04, theme-refactoring-06]
|
||||||
|
tags: [implementation, detection]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Implement system theme detection based on terminal palette
|
||||||
|
- Generate system theme from terminal colors
|
||||||
|
- Detect light/dark mode based on terminal background
|
||||||
|
- Handle terminal color palette limitations
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/system-theme.ts` - System theme detection utilities
|
||||||
|
- `src/utils/color-generation.ts` - Color generation helpers
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 7.1: Create `src/utils/system-theme.ts`
|
||||||
|
- Implement `detectSystemTheme()` function
|
||||||
|
- Detect terminal background and foreground colors
|
||||||
|
- Determine light/dark mode based on luminance
|
||||||
|
- Return detected theme information
|
||||||
|
|
||||||
|
- Step 7.2: Implement terminal palette detection
|
||||||
|
- Detect terminal colors using `@opentui/core` renderer
|
||||||
|
- Get default background and foreground colors
|
||||||
|
- Extract color palette (16 standard colors)
|
||||||
|
- Handle missing or invalid palette data
|
||||||
|
|
||||||
|
- Step 7.3: Create `src/utils/color-generation.ts`
|
||||||
|
- Implement `generateGrayScale(bg: RGBA, isDark: boolean): Record<number, RGBA>`
|
||||||
|
- Implement `generateMutedTextColor(bg: RGBA, isDark: boolean): RGBA`
|
||||||
|
- Implement `tint(base: RGBA, overlay: RGBA, alpha: number): RGBA`
|
||||||
|
- Generate gray scale based on background luminance
|
||||||
|
- Generate muted text colors for readability
|
||||||
|
|
||||||
|
- Step 7.4: Create system theme JSON generator
|
||||||
|
- Implement `generateSystemTheme(colors: TerminalColors, mode: "dark" | "light"): ThemeJson`
|
||||||
|
- Use ANSI color references for primary colors
|
||||||
|
- Generate appropriate background colors
|
||||||
|
- Generate diff colors with alpha blending
|
||||||
|
- Generate markdown and syntax colors
|
||||||
|
|
||||||
|
- Step 7.5: Add system theme caching
|
||||||
|
- Cache terminal palette detection results
|
||||||
|
- Handle palette cache invalidation
|
||||||
|
- Support manual cache clear on SIGUSR2
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `detectSystemTheme` returns correct mode
|
||||||
|
- Test `generateGrayScale` produces correct grays
|
||||||
|
- Test `generateMutedTextColor` produces readable colors
|
||||||
|
- Test `tint` produces correct blended colors
|
||||||
|
- Test `generateSystemTheme` produces valid theme JSON
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test system theme detection in terminal
|
||||||
|
- Test theme generation with actual terminal palette
|
||||||
|
- Verify light/dark mode detection is accurate
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/utils/system-theme.ts` file exists with detection functions
|
||||||
|
- `src/utils/color-generation.ts` file exists with generation helpers
|
||||||
|
- System theme detection works correctly
|
||||||
|
- Light/dark mode detection is accurate
|
||||||
|
- Theme generation produces valid JSON
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `bun test src/utils/system-theme.test.ts`
|
||||||
|
- Run: `bun test src/utils/color-generation.test.ts`
|
||||||
|
- Test system theme detection manually in terminal
|
||||||
|
- Verify theme colors are readable
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's system theme detection as reference
|
||||||
|
- Handle terminal transparency gracefully
|
||||||
|
- Add fallback for terminals without palette support
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 428-535)
|
||||||
83
tasks/subtasks/theme-refactoring-08-syntax-highlighting.md
Normal file
83
tasks/subtasks/theme-refactoring-08-syntax-highlighting.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 08. Add Syntax Highlighting Integration
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-08
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P1
|
||||||
|
depends_on: [theme-refactoring-04, theme-refactoring-05]
|
||||||
|
tags: [implementation, syntax]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Add syntax highlighting support using theme colors
|
||||||
|
- Generate syntax rules from theme definitions
|
||||||
|
- Support markdown syntax highlighting
|
||||||
|
- Integrate with OpenTUI syntax highlighting
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/syntax-highlighter.ts` - Syntax highlighting utilities
|
||||||
|
- `src/utils/syntax-rules.ts` - Syntax rule generation
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 8.1: Create `src/utils/syntax-rules.ts`
|
||||||
|
- Implement `getSyntaxRules(theme: ThemeColors)` function
|
||||||
|
- Define syntax scopes and their mappings
|
||||||
|
- Map theme colors to syntax scopes:
|
||||||
|
- Default text
|
||||||
|
- Keywords (return, conditional, repeat, coroutine)
|
||||||
|
- Types (function, class, module)
|
||||||
|
- Variables (parameter, member, builtin)
|
||||||
|
- Strings, numbers, booleans
|
||||||
|
- Comments
|
||||||
|
- Operators and punctuation
|
||||||
|
- Markdown-specific scopes (headings, bold, italic, links)
|
||||||
|
- Diff scopes (added, removed, context)
|
||||||
|
- Add style properties (foreground, bold, italic, underline)
|
||||||
|
|
||||||
|
- Step 8.2: Create `src/utils/syntax-highlighter.ts`
|
||||||
|
- Implement `generateSyntax(theme: ThemeColors)` function
|
||||||
|
- Implement `generateSubtleSyntax(theme: ThemeColors)` function
|
||||||
|
- Apply opacity to syntax colors for subtle highlighting
|
||||||
|
- Use theme's `thinkingOpacity` property
|
||||||
|
|
||||||
|
- Step 8.3: Integrate with OpenTUI syntax highlighting
|
||||||
|
- Import `SyntaxStyle` from `@opentui/core`
|
||||||
|
- Use `SyntaxStyle.fromTheme()` to create syntax styles
|
||||||
|
- Apply syntax styles to code components
|
||||||
|
|
||||||
|
- Step 8.4: Add syntax scope mappings
|
||||||
|
- Map common programming language scopes
|
||||||
|
- Map markdown and markup scopes
|
||||||
|
- Map diff and git scopes
|
||||||
|
- Add scope aliases for common patterns
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `getSyntaxRules` generates correct rules
|
||||||
|
- Test `generateSyntax` creates valid syntax styles
|
||||||
|
- Test `generateSubtleSyntax` applies opacity correctly
|
||||||
|
- Test syntax rules cover all expected scopes
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test syntax highlighting with different themes
|
||||||
|
- Verify syntax colors match theme definitions
|
||||||
|
- Test markdown highlighting
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/utils/syntax-rules.ts` file exists with rule generation
|
||||||
|
- `src/utils/syntax-highlighter.ts` file exists with style generation
|
||||||
|
- Syntax highlighting works with theme colors
|
||||||
|
- Markdown highlighting is supported
|
||||||
|
- Syntax rules cover common programming patterns
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `bun test src/utils/syntax-rules.test.ts`
|
||||||
|
- Run: `bun test src/utils/syntax-highlighter.test.ts`
|
||||||
|
- Test syntax highlighting in application
|
||||||
|
- Verify syntax colors are readable
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Use opencode's syntax rule generation as reference
|
||||||
|
- Include comprehensive scope mappings
|
||||||
|
- Support common programming languages
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 622-1152)
|
||||||
77
tasks/subtasks/theme-refactoring-09-theme-utils.md
Normal file
77
tasks/subtasks/theme-refactoring-09-theme-utils.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 09. Update Theme Utilities and CSS Variable Application
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-09
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P1
|
||||||
|
depends_on: [theme-refactoring-04]
|
||||||
|
tags: [implementation, utilities]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Update existing theme utilities to work with JSON theme structure
|
||||||
|
- Refactor CSS variable application logic
|
||||||
|
- Add support for theme color references
|
||||||
|
- Ensure backward compatibility with existing components
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/theme.ts` - Updated theme utilities
|
||||||
|
- `src/utils/theme-css.ts` - CSS variable application utilities
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 9.1: Update `src/utils/theme.ts`
|
||||||
|
- Refactor `applyTheme()` to accept `ThemeColors` type
|
||||||
|
- Keep existing function signature for backward compatibility
|
||||||
|
- Update to work with resolved theme colors
|
||||||
|
|
||||||
|
- Step 9.2: Create `src/utils/theme-css.ts`
|
||||||
|
- Implement `applyThemeToCSS(theme: ThemeColors)` function
|
||||||
|
- Apply theme colors as CSS custom properties
|
||||||
|
- Support all theme color properties
|
||||||
|
- Handle layer backgrounds if present
|
||||||
|
|
||||||
|
- Step 9.3: Update theme attribute handling
|
||||||
|
- Implement `setThemeAttribute(themeName: string)` function
|
||||||
|
- Update to use data-theme attribute
|
||||||
|
- Support system theme attribute
|
||||||
|
|
||||||
|
- Step 9.4: Add color reference support
|
||||||
|
- Implement `resolveColorReference(color: string): string` function
|
||||||
|
- Convert color references to CSS values
|
||||||
|
- Handle hex colors, color references, and RGBA
|
||||||
|
|
||||||
|
- Step 9.5: Add theme utility functions
|
||||||
|
- Implement `getThemeByName(name: string): ThemeJson | undefined`
|
||||||
|
- Implement `getDefaultTheme(): ThemeJson`
|
||||||
|
- Implement `getAllThemes(): ThemeJson[]`
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test `applyThemeToCSS` applies all colors correctly
|
||||||
|
- Test `setThemeAttribute` sets attribute correctly
|
||||||
|
- Test `resolveColorReference` converts references correctly
|
||||||
|
- Test theme utility functions return correct results
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test theme application in browser
|
||||||
|
- Verify CSS variables are updated correctly
|
||||||
|
- Test theme attribute changes
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- `src/utils/theme.ts` is updated and backward compatible
|
||||||
|
- `src/utils/theme-css.ts` file exists with CSS utilities
|
||||||
|
- CSS variables are applied correctly
|
||||||
|
- Color references are resolved properly
|
||||||
|
- Theme utilities are functional
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun run typecheck` - Should pass
|
||||||
|
- Run: `bun test src/utils/theme.test.ts`
|
||||||
|
- Run: `bun test src/utils/theme-css.test.ts`
|
||||||
|
- Test theme application in browser
|
||||||
|
- Verify CSS variables are applied correctly
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Maintain backward compatibility with existing code
|
||||||
|
- Use CSS custom properties for theming
|
||||||
|
- Ensure all theme colors are applied
|
||||||
|
- Reference: `/home/mike/code/PodTui/src/utils/theme.ts` (original file)
|
||||||
80
tasks/subtasks/theme-refactoring-10-theme-switching.md
Normal file
80
tasks/subtasks/theme-refactoring-10-theme-switching.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# 10. Test Theme Switching and Light/Dark Mode
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-10
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P0
|
||||||
|
depends_on: [theme-refactoring-05, theme-refactoring-09]
|
||||||
|
tags: [testing, verification]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Comprehensive testing of theme switching functionality
|
||||||
|
- Verify light/dark mode switching works correctly
|
||||||
|
- Test all theme JSON files load and apply correctly
|
||||||
|
- Ensure theme persistence works across sessions
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- `src/utils/theme.test.ts` - Theme utility tests
|
||||||
|
- `src/context/ThemeContext.test.ts` - Context tests
|
||||||
|
- Test results showing all themes work correctly
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 10.1: Create `src/utils/theme.test.ts`
|
||||||
|
- Test theme loading functions
|
||||||
|
- Test theme resolution logic
|
||||||
|
- Test color reference resolution
|
||||||
|
- Test ANSI color conversion
|
||||||
|
|
||||||
|
- Step 10.2: Create `src/context/ThemeContext.test.ts`
|
||||||
|
- Test theme context initialization
|
||||||
|
- Test theme switching
|
||||||
|
- Test mode switching
|
||||||
|
- Test system theme detection
|
||||||
|
- Test localStorage persistence
|
||||||
|
- Test reactive theme updates
|
||||||
|
|
||||||
|
- Step 10.3: Manual testing
|
||||||
|
- Test switching between all themes (catppuccin, gruvbox, tokyo, nord)
|
||||||
|
- Test light/dark mode switching
|
||||||
|
- Test system theme detection
|
||||||
|
- Test theme persistence (close and reopen app)
|
||||||
|
- Test custom theme loading
|
||||||
|
|
||||||
|
- Step 10.4: Visual verification
|
||||||
|
- Verify all theme colors are correct
|
||||||
|
- Check readability of text colors
|
||||||
|
- Verify background colors are appropriate
|
||||||
|
- Check that all UI elements use theme colors
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Run all theme utility tests
|
||||||
|
- Run all theme context tests
|
||||||
|
- Verify all tests pass
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test theme switching in application
|
||||||
|
- Test light/dark mode switching
|
||||||
|
- Test theme persistence
|
||||||
|
- Test system theme detection
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- All unit tests pass
|
||||||
|
- All integration tests pass
|
||||||
|
- Theme switching works correctly
|
||||||
|
- Light/dark mode switching works correctly
|
||||||
|
- All themes load and apply correctly
|
||||||
|
- Theme persistence works across sessions
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun test src/utils/theme.test.ts`
|
||||||
|
- Run: `bun test src/context/ThemeContext.test.ts`
|
||||||
|
- Run: `bun test` - Run all tests
|
||||||
|
- Manual testing of all themes
|
||||||
|
- Visual verification of theme appearance
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Test with actual terminal to verify system theme detection
|
||||||
|
- Verify all theme colors are visually appealing
|
||||||
|
- Check for any color contrast issues
|
||||||
|
- Test edge cases (missing themes, invalid colors)
|
||||||
77
tasks/subtasks/theme-refactoring-11-custom-themes.md
Normal file
77
tasks/subtasks/theme-refactoring-11-custom-themes.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 11. Verify Custom Theme Loading and Persistence
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: theme-refactoring-11
|
||||||
|
feature: theme-refactoring-json-format
|
||||||
|
priority: P1
|
||||||
|
depends_on: [theme-refactoring-06, theme-refactoring-10]
|
||||||
|
tags: [testing, verification]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
- Test custom theme loading from directories
|
||||||
|
- Verify theme persistence works correctly
|
||||||
|
- Test custom theme switching
|
||||||
|
- Ensure custom themes are loaded on app start
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
- Test results for custom theme loading
|
||||||
|
- Documentation for custom theme format
|
||||||
|
- Verification that custom themes work correctly
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- Step 11.1: Create test theme files
|
||||||
|
- Create test theme in `~/.config/podtui/themes/`
|
||||||
|
- Create test theme in `./.podtui/themes/`
|
||||||
|
- Create test theme in `./themes/`
|
||||||
|
|
||||||
|
- Step 11.2: Test custom theme loading
|
||||||
|
- Start application and verify custom themes are loaded
|
||||||
|
- Switch to custom theme
|
||||||
|
- Verify custom theme applies correctly
|
||||||
|
|
||||||
|
- Step 11.3: Test theme persistence
|
||||||
|
- Set custom theme
|
||||||
|
- Close application
|
||||||
|
- Reopen application
|
||||||
|
- Verify custom theme is still selected
|
||||||
|
|
||||||
|
- Step 11.4: Test theme discovery
|
||||||
|
- List all available themes
|
||||||
|
- Verify custom themes appear in list
|
||||||
|
- Test switching to custom themes
|
||||||
|
|
||||||
|
- Step 11.5: Test invalid theme handling
|
||||||
|
- Create invalid theme JSON
|
||||||
|
- Verify error is handled gracefully
|
||||||
|
- Verify app doesn't crash
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- Unit:
|
||||||
|
- Test custom theme loading functions
|
||||||
|
- Test theme discovery
|
||||||
|
- Test invalid theme handling
|
||||||
|
|
||||||
|
- Integration/e2e:
|
||||||
|
- Test custom theme loading from directories
|
||||||
|
- Test theme persistence
|
||||||
|
- Test theme discovery
|
||||||
|
- Test invalid theme handling
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- Custom themes can be loaded from directories
|
||||||
|
- Custom themes persist across sessions
|
||||||
|
- Custom themes appear in theme list
|
||||||
|
- Invalid themes are handled gracefully
|
||||||
|
- Theme discovery works correctly
|
||||||
|
|
||||||
|
validation:
|
||||||
|
- Run: `bun test src/utils/custom-themes.test.ts`
|
||||||
|
- Run: `bun test` - Run all tests
|
||||||
|
- Manual testing of custom themes
|
||||||
|
- Verify themes persist after restart
|
||||||
|
|
||||||
|
notes:
|
||||||
|
- Create documentation for custom theme format
|
||||||
|
- Reference: `/home/mike/code/PodTui/opencode/packages/opencode/src/cli/cmd/tui/context/theme.tsx` (lines 394-419)
|
||||||
|
- Test with multiple custom themes
|
||||||
|
- Verify all custom themes work correctly
|
||||||
Reference in New Issue
Block a user