understanding

This commit is contained in:
2026-02-06 16:29:09 -05:00
parent bfea6816ef
commit 1cee931913
13 changed files with 161 additions and 398 deletions

View File

@@ -50,7 +50,7 @@ export function Layout(props: LayoutProps) {
flexDirection="column"
width="100%"
height="100%"
backgroundColor={theme.background}
backgroundColor={theme.surface}
>
{/* Header - tab bar */}
<Show when={props.header}>
@@ -119,18 +119,6 @@ export function Layout(props: LayoutProps) {
)}
</For>
</box>
{/* Footer - status/nav bar */}
<Show when={props.footer}>
<box
style={{
height: 2,
backgroundColor: theme.surface ?? theme.backgroundPanel,
}}
>
<box style={{ padding: 1 }}>{props.footer}</box>
</box>
</Show>
</box>
);
}

View File

@@ -1,20 +1,25 @@
import type { ThemeColors, ThemeDefinition, ThemeName } from "../types/settings"
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" }
import type {
ThemeColors,
ThemeDefinition,
ThemeName,
} from "../types/settings";
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" };
export const DEFAULT_THEME: ThemeColors = {
...BASE_THEME_COLORS,
layerBackgrounds: BASE_LAYER_BACKGROUND,
}
};
export const THEME_JSON: Record<string, ThemeDefinition> = {
opencode: opencode as ThemeDefinition,
catppuccin: catppuccin as ThemeDefinition,
gruvbox: gruvbox as ThemeDefinition,
tokyo: tokyo as ThemeDefinition,
nord: nord as ThemeDefinition,
}
};

View File

@@ -93,9 +93,6 @@ type ThemeResolved = {
* This ensures children are NOT rendered until the theme is ready,
* preventing "useTheme must be used within a ThemeProvider" errors.
*
* The key insight from opencode's implementation is that the provider
* uses `<Show when={ready}>` to gate rendering, so components can
* safely call useTheme() without checking ready state.
*/
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
@@ -121,8 +118,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
);
})
.catch(() => {
// If custom themes fail to load, fall back to opencode theme
setStore("active", "opencode");
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
@@ -206,7 +202,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "opencode";
draft.active = "catppuccin";
draft.ready = true;
}),
);

View File

@@ -5,6 +5,8 @@
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { htmlToText } from "@/utils/html-to-text";
import { useTheme } from "@/context/ThemeContext";
interface FeedItemProps {
feed: Feed;
@@ -36,6 +38,7 @@ export function FeedItem(props: FeedItemProps) {
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
@@ -51,8 +54,8 @@ export function FeedItem(props: FeedItemProps) {
{props.isSelected ? ">" : " "}
</text>
<text fg={visibilityColor()}>{visibilityIcon()}</text>
<text fg={props.isSelected ? "white" : undefined}>
{props.feed.customName || props.feed.podcast.title}
<text fg={props.isSelected ? "white" : theme.accent}>
{htmlToText(props.feed.customName || props.feed.podcast.title)}
</text>
{props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
</box>
@@ -76,12 +79,13 @@ export function FeedItem(props: FeedItemProps) {
</text>
<text fg={visibilityColor()}>{visibilityIcon()}</text>
<text fg="yellow">{pinnedIndicator()}</text>
<text fg={props.isSelected ? "white" : undefined}>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
<text fg={props.isSelected ? "white" : theme.text}>
<strong>
{htmlToText(props.feed.customName || props.feed.podcast.title)}
</strong>
</text>
</box>
{/* Details row */}
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<text fg="gray">
@@ -93,7 +97,6 @@ export function FeedItem(props: FeedItemProps) {
)}
</box>
{/* Description (truncated) */}
{props.feed.podcast.description && (
<box paddingLeft={4} paddingTop={0}>
<text fg="gray">

View File

@@ -9,6 +9,8 @@ import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
import { htmlToText } from "@/utils/html-to-text";
type FeedPageProps = {
focused: boolean;
@@ -67,8 +69,13 @@ export function FeedPage(props: FeedPageProps) {
}
});
const { theme } = useTheme();
return (
<box flexDirection="column" height="100%">
<box
backgroundColor={theme.background}
flexDirection="column"
height="100%"
>
{/* Status line */}
<Show when={isRefreshing()}>
<text fg="yellow">Refreshing feeds...</text>
@@ -104,7 +111,7 @@ export function FeedPage(props: FeedPageProps) {
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
{index() === selectedIndex() ? ">" : " "}
</text>
<text fg={index() === selectedIndex() ? "white" : undefined}>
<text fg={index() === selectedIndex() ? "white" : theme.text}>
{item.episode.title}
</text>
</box>

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "transparent",
"background": "#181825",
"surface": "#1e1e2e",
"primary": "#89b4fa",
"secondary": "#cba6f7",
@@ -11,7 +10,7 @@
"warning": "#fab387",
"error": "#f38ba8",
"success": "#a6e3a1",
"layer0": "transparent",
"layer0": "#181825",
"layer1": "#181825",
"layer2": "#11111b",
"layer3": "#0a0a0f"

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "transparent",
"background": "#282828",
"surface": "#282828",
"primary": "#fabd2f",
"secondary": "#83a598",
@@ -11,7 +10,7 @@
"warning": "#fabd2f",
"error": "#fb4934",
"success": "#b8bb26",
"layer0": "transparent",
"layer0": "#282828",
"layer1": "#32302a",
"layer2": "#1d2021",
"layer3": "#0d0c0c"

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "transparent",
"background": "#2e3440",
"surface": "#2e3440",
"primary": "#88c0d0",
"secondary": "#81a1c1",
@@ -11,7 +10,7 @@
"warning": "#ebcb8b",
"error": "#bf616a",
"success": "#a3be8c",
"layer0": "transparent",
"layer0": "#2e3440",
"layer1": "#3b4252",
"layer2": "#242933",
"layer3": "#1a1c23"

View File

@@ -1,245 +0,0 @@
{
"$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"
}
}
}

View File

@@ -1,5 +1,4 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {},
"theme": {
"primary": "#000000",

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"background": "transparent",
"background": "#0f0f15",
"surface": "#1a1b26",
"primary": "#7aa2f7",
"secondary": "#bb9af7",
@@ -11,7 +10,7 @@
"warning": "#e0af68",
"error": "#f7768e",
"success": "#9ece6a",
"layer0": "transparent",
"layer0": "#0f0f15",
"layer1": "#16161e",
"layer2": "#0f0f15",
"layer3": "#08080b"

View File

@@ -1,83 +1,89 @@
import type { RGBA } from "@opentui/core"
import type { ColorValue, ThemeJson, Variant } from "./theme-schema"
import type { RGBA } from "@opentui/core";
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
export type ThemeName = "system" | "opencode" | "catppuccin" | "gruvbox" | "tokyo" | "nord" | "custom"
export type ThemeName =
| "system"
| "catppuccin"
| "gruvbox"
| "tokyo"
| "nord"
| "custom";
export type LayerBackgrounds = {
layer0: ColorValue
layer1: ColorValue
layer2: ColorValue
layer3: ColorValue
}
layer0: ColorValue;
layer1: ColorValue;
layer2: ColorValue;
layer3: ColorValue;
};
export type ThemeColors = {
background: ColorValue
surface: ColorValue
primary: ColorValue
secondary: ColorValue
accent: ColorValue
text: ColorValue
muted: ColorValue
warning: ColorValue
error: ColorValue
success: ColorValue
layerBackgrounds?: LayerBackgrounds
}
background: ColorValue;
surface: ColorValue;
primary: ColorValue;
secondary: ColorValue;
accent: ColorValue;
text: ColorValue;
muted: ColorValue;
warning: ColorValue;
error: ColorValue;
success: ColorValue;
layerBackgrounds?: LayerBackgrounds;
};
export type ThemeVariant = {
name: string
colors: ThemeColors
}
name: string;
colors: ThemeColors;
};
export type ThemeToken = {
[key: string]: string
}
[key: string]: string;
};
export type ResolvedTheme = Record<string, RGBA> & {
layerBackgrounds: Record<string, RGBA>
_hasSelectedListItemText: boolean
thinkingOpacity: number
}
layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean;
thinkingOpacity: number;
};
export type DesktopTheme = {
name: string
variants: ThemeVariant[]
defaultVariant: string
tokens: ThemeToken
}
name: string;
variants: ThemeVariant[];
defaultVariant: string;
tokens: ThemeToken;
};
export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 32) */
bars: number
bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number
sensitivity: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction: number
noiseReduction: number;
/** Low frequency cutoff in Hz (default: 50) */
lowCutOff: number
lowCutOff: number;
/** High frequency cutoff in Hz (default: 10000) */
highCutOff: number
}
highCutOff: number;
};
export type AppSettings = {
theme: ThemeName
fontSize: number
playbackSpeed: number
downloadPath: string
visualizer: VisualizerSettings
}
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
downloadPath: string;
visualizer: VisualizerSettings;
};
export type UserPreferences = {
showExplicit: boolean
autoDownload: boolean
}
showExplicit: boolean;
autoDownload: boolean;
};
export type AppState = {
settings: AppSettings
preferences: UserPreferences
customTheme: ThemeColors
}
settings: AppSettings;
preferences: UserPreferences;
customTheme: ThemeColors;
};
export type ThemeMode = "dark" | "light"
export type ThemeVariantValue = Variant
export type ThemeDefinition = ThemeJson
export type ThemeMode = "dark" | "light";
export type ThemeVariantValue = Variant;
export type ThemeDefinition = ThemeJson;

View File

@@ -3,46 +3,54 @@
* Handles dynamic theme switching by updating CSS custom properties
*/
import { RGBA, type TerminalColors } from "@opentui/core"
import type { ThemeColors } from "../types/settings"
import type { ColorValue, ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
import { getCustomThemes } from "./custom-themes"
import { resolveTheme as resolveThemeJson } from "./theme-resolver"
import { generateSystemTheme } from "./system-theme"
import { RGBA, type TerminalColors } from "@opentui/core";
import type { ThemeColors } from "../types/settings";
import type { ColorValue, ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
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})`
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
}
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
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))
if (typeof document === "undefined") return;
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));
const layers = theme.layerBackgrounds as Record<string, ColorValue> | undefined
const layers = theme.layerBackgrounds as
| Record<string, ColorValue>
| undefined;
if (layers) {
root.style.setProperty("--color-layer0", toCss(layers.layer0))
root.style.setProperty("--color-layer1", toCss(layers.layer1))
root.style.setProperty("--color-layer2", toCss(layers.layer2))
root.style.setProperty("--color-layer3", toCss(layers.layer3))
root.style.setProperty("--color-layer0", toCss(layers.layer0));
root.style.setProperty("--color-layer1", toCss(layers.layer1));
root.style.setProperty("--color-layer2", toCss(layers.layer2));
root.style.setProperty("--color-layer3", toCss(layers.layer3));
}
}
@@ -50,46 +58,46 @@ export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
* Get theme mode from system preference
*/
export function getSystemThemeMode(): "dark" | "light" {
if (typeof window === "undefined") return "dark"
if (typeof window === "undefined") return "dark";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
return prefersDark ? "dark" : "light"
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
}
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return
const root = document.documentElement
root.setAttribute("data-theme", themeName)
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes()
return await getCustomThemes();
}
export async function loadTheme(name: string) {
const themes = await loadThemes()
return themes[name]
const themes = await loadThemes();
return themes[name];
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
return resolveThemeJson(theme, mode)
return resolveThemeJson(theme, mode);
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
mode: "dark" | "light",
system?: TerminalColors
system?: TerminalColors,
) {
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode)
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.opencode
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.opencode, mode)
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode)
return resolveThemeJson(theme, mode);
}