diff --git a/desktop/src/components/AgentPanel.tsx b/desktop/src/components/AgentPanel.tsx index deae4df..6b25a0e 100644 --- a/desktop/src/components/AgentPanel.tsx +++ b/desktop/src/components/AgentPanel.tsx @@ -38,6 +38,7 @@ import { } from "../lib/queries"; import { useToast } from "../state/toast"; import { HAS_NATIVE_FOLDER_PICKER, pickWorkspace } from "../lib/api"; +import { useUiTheme } from "../lib/theme"; import TerminalPane from "./TerminalPane"; import AgentSessionPicker from "./AgentSessionPicker"; import AgentPromptModal from "./AgentPromptModal"; @@ -57,6 +58,7 @@ const MIN_W = 340; * hidden behind a spinner (see `useStartAgent`). */ export default function AgentPanel() { + const ui = useUiTheme(); const [open, setOpen] = useAtom(agentPanelOpenAtom); const [fullscreen, setFullscreen] = useAtom(agentPanelFullscreenAtom); const [width, setWidth] = useAtom(agentPanelWidthAtom); @@ -170,8 +172,8 @@ export default function AgentPanel() { style={fullscreen ? undefined : { width }} className={`${!open ? "hidden " : ""}${ fullscreen - ? "absolute inset-0 z-30 flex flex-col bg-[#0a0d13]" - : "relative flex shrink-0 flex-col border-l border-white/10 bg-[#0a0d13]" + ? `absolute inset-0 z-30 flex flex-col ${ui.surface}` + : `relative flex shrink-0 flex-col border-l border-white/10 ${ui.surface}` }`} > {!fullscreen && ( diff --git a/desktop/src/components/LogsPane.tsx b/desktop/src/components/LogsPane.tsx index 877779b..336d985 100644 --- a/desktop/src/components/LogsPane.tsx +++ b/desktop/src/components/LogsPane.tsx @@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { registerWebLinks } from "../lib/xtermLinks"; +import { useUiTheme } from "../lib/theme"; import { openUrl } from "@tauri-apps/plugin-opener"; import { Button, Input, Switch, Tooltip } from "@heroui/react"; import { @@ -16,19 +17,16 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; import { api, onLogLine } from "../lib/api"; import { useToast } from "../state/toast"; -const THEME = { - background: "#0a0d13", - // Near-white rather than the muted grey the rest of the chrome uses: this is - // the content, not a label, and #c6ccd8 on #0a0d13 reads as dim next to it. - foreground: "#e8ecf5", - cursor: "#0a0d13", - selectionBackground: "rgba(124,139,255,0.35)", -}; /** Read-only console log viewer: initial snapshot + live `logs -f` follow, an * in-buffer quick search, and copy-on-select. Toggle to the boot log for * early-failure diagnostics. */ export default function LogsPane({ machineId }: { machineId: string }) { + const ui = useUiTheme(); + + useEffect(() => { + if (termRef.current) termRef.current.options.theme = ui.logTerm; + }, [ui]); const hostRef = useRef(null); const termRef = useRef(null); const searchRef = useRef(null); @@ -79,7 +77,7 @@ export default function LogsPane({ machineId }: { machineId: string }) { cursorInactiveStyle: "none", convertEol: true, scrollback: 20000, - theme: THEME, + theme: ui.logTerm, }); fit = new FitAddon(); const search = new SearchAddon(); diff --git a/desktop/src/components/MachineDetail.tsx b/desktop/src/components/MachineDetail.tsx index b7b4d1c..cea8e69 100644 --- a/desktop/src/components/MachineDetail.tsx +++ b/desktop/src/components/MachineDetail.tsx @@ -43,6 +43,7 @@ import { useStopMachine, } from "../lib/queries"; import { ago, exitLabel, isUnikraft, kindColor, shortId } from "../lib/format"; +import { useUiTheme } from "../lib/theme"; import { useToast } from "../state/toast"; import SnapshotsPane from "./SnapshotsPane"; import TerminalPane from "./TerminalPane"; @@ -117,6 +118,7 @@ function Inspect({ m }: { m: Machine }) { } export default function MachineDetail() { + const ui = useUiTheme(); const [selected, setSelected] = useAtom(selectedMachineAtom); const { data: machines = [] } = useMachines(); const stopMutation = useStopMachine(); @@ -416,12 +418,12 @@ export default function MachineDetail() {
{tab === "terminal" && m.running && !isUnikraft(m.kind) && ( -
+
)} {tab === "logs" && ( -
+
)} diff --git a/desktop/src/components/SettingsModal.tsx b/desktop/src/components/SettingsModal.tsx index 7b40760..41dc1ad 100644 --- a/desktop/src/components/SettingsModal.tsx +++ b/desktop/src/components/SettingsModal.tsx @@ -19,7 +19,9 @@ import { IconCircleCheck, IconAlertTriangle, } from "@tabler/icons-react"; -import { settingsOpenAtom } from "../state/atoms"; +import { settingsOpenAtom, + themeAtom, +} from "../state/atoms"; import { useDefaultCache, useProbe, @@ -45,6 +47,7 @@ const looksLikeUrl = (s: string) => type FormValues = z.infer; export default function SettingsModal() { + const [theme, setTheme] = useAtom(themeAtom); const [open, setOpen] = useAtom(settingsOpenAtom); const { data: settings } = useSettings(); const { data: defaultCachePath } = useDefaultCache(); @@ -109,6 +112,34 @@ export default function SettingsModal() { Settings + {/* Appearance first: it is the setting people come looking for. */} +
+ +
+ {( + [ + ["night-rider", "Night Rider"], + ["dark", "Classic Dark"], + ] as const + ).map(([key, label]) => ( + + ))} +
+

+ Also: the Appearance button in the sidebar, the command palette, or the{" "} + t key. +

+
+
       {text}
     
); diff --git a/desktop/src/components/ShortcutsModal.tsx b/desktop/src/components/ShortcutsModal.tsx index 22a1b3f..b636c05 100644 --- a/desktop/src/components/ShortcutsModal.tsx +++ b/desktop/src/components/ShortcutsModal.tsx @@ -17,6 +17,8 @@ const GROUPS: { title: string; items: [string[], string][] }[] = [ [["⌘", "K"], "Open command palette"], [["?"], "Show this help"], [["R"], "Refresh"], + [["F"], "Focus the filter box"], + [["T"], "Cycle theme (Night Rider / Classic Dark)"], [["Esc"], "Close dialog / palette"], ], }, diff --git a/desktop/src/components/Sidebar.tsx b/desktop/src/components/Sidebar.tsx index 1a30074..ee5c7f9 100644 --- a/desktop/src/components/Sidebar.tsx +++ b/desktop/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { IconNetwork, IconCamera, IconBrandDocker, + IconMoon, IconSettings, IconKeyboard, IconCloud, @@ -18,6 +19,7 @@ import { Tooltip } from "@heroui/react"; import { settingsOpenAtom, shortcutsOpenAtom, + themeAtom, viewAtom, } from "../state/atoms"; import { @@ -66,6 +68,7 @@ export default function Sidebar() { true, !!dockerStatus?.running, ); + const [theme, setTheme] = useAtom(themeAtom); const [, setSettingsOpen] = useAtom(settingsOpenAtom); const [, setShortcutsOpen] = useAtom(shortcutsOpenAtom); @@ -97,7 +100,7 @@ export default function Sidebar() { className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${ active ? "bg-primary/15 text-foreground shadow-[inset_0_0_0_1px] shadow-primary/25" - : "text-foreground-500 hover:bg-white/5 hover:text-foreground-300" + : "text-foreground-500 hover:bg-default-100/70 hover:text-foreground-300" }`} > {counts[it.key]} @@ -124,10 +127,28 @@ export default function Sidebar() {
+ + + diff --git a/desktop/src/components/Skeletons.tsx b/desktop/src/components/Skeletons.tsx index 3f2ab4f..f2facb4 100644 --- a/desktop/src/components/Skeletons.tsx +++ b/desktop/src/components/Skeletons.tsx @@ -1,10 +1,11 @@ import ContentLoader from "react-content-loader"; - -const BG = "#1b2130"; -const FG = "#2b3446"; +import { useUiTheme } from "../lib/theme"; /** One shimmering table row (scales to the container width via viewBox). */ function RowSkeleton() { + // Themed, not constant: a gray shimmer over a purple surface reads as a + // rendering glitch rather than as loading. + const { skeleton } = useUiTheme(); return ( @@ -29,6 +30,7 @@ function RowSkeleton() { /** A placeholder table shown while a list query loads for the first time. */ export function TableSkeleton({ rows = 6 }: { rows?: number }) { + const { skeleton } = useUiTheme(); return (
{/* Header shimmer */} @@ -38,8 +40,8 @@ export function TableSkeleton({ rows = 6 }: { rows?: number }) { height={34} viewBox="0 0 1000 34" preserveAspectRatio="none" - backgroundColor={BG} - foregroundColor={FG} + backgroundColor={skeleton.bg} + foregroundColor={skeleton.fg} className="border-b border-white/10" > @@ -57,6 +59,7 @@ export function TableSkeleton({ rows = 6 }: { rows?: number }) { /** One shimmering flavor card. */ function CardSkeleton() { + const { skeleton } = useUiTheme(); return ( @@ -96,6 +99,7 @@ export function CardGridSkeleton({ cards = 8 }: { cards?: number }) { /** A small inline block skeleton (e.g. for panels / cards). */ export function BlockSkeleton({ height = 120 }: { height?: number }) { + const { skeleton } = useUiTheme(); return ( diff --git a/desktop/src/components/TerminalPane.tsx b/desktop/src/components/TerminalPane.tsx index 06675f9..48d6cc1 100644 --- a/desktop/src/components/TerminalPane.tsx +++ b/desktop/src/components/TerminalPane.tsx @@ -2,34 +2,12 @@ import { useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { registerWebLinks } from "../lib/xtermLinks"; +import { useUiTheme } from "../lib/theme"; import { openUrl } from "@tauri-apps/plugin-opener"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { api, onTermData, onTermExit } from "../lib/api"; import { HOST_MACHINE } from "../state/atoms"; -const THEME = { - background: "#0a0d13", - foreground: "#dfe4ee", - cursor: "#7c8bff", - cursorAccent: "#0a0d13", - selectionBackground: "rgba(124,139,255,0.35)", - black: "#11151c", - red: "#ff6b6b", - green: "#7ee787", - yellow: "#f0c674", - blue: "#79b8ff", - magenta: "#c398ff", - cyan: "#66d9e8", - white: "#c9d1d9", - brightBlack: "#4b5262", - brightRed: "#ff8585", - brightGreen: "#a3f7b5", - brightYellow: "#ffd479", - brightBlue: "#a5c8ff", - brightMagenta: "#d7b6ff", - brightCyan: "#8ce8f0", - brightWhite: "#f0f6fc", -}; /** An interactive PTY session (`bsdkrun exec -t `), streamed over * Tauri events into an xterm.js instance. */ @@ -40,6 +18,14 @@ export default function TerminalPane({ machineId: string; command?: string[]; }) { + const ui = useUiTheme(); + + // The live Terminal, for re-theming: xterm is a canvas, so CSS variables + // never reach it and the theme has to be pushed in. + const termInst = useRef(null); + useEffect(() => { + if (termInst.current) termInst.current.options.theme = ui.term; + }, [ui]); const hostRef = useRef(null); const [status, setStatus] = useState<"connecting" | "open" | "closed">( "connecting", @@ -71,7 +57,7 @@ export default function TerminalPane({ cursorBlink: true, cursorStyle: "bar", scrollback: 5000, - theme: THEME, + theme: ui.term, allowProposedApi: true, }); fit = new FitAddon(); @@ -83,6 +69,7 @@ export default function TerminalPane({ openUrl(uri).catch(() => {}); }); term.open(hostRef.current); + termInst.current = term; fit.fit(); term.onData((data) => { diff --git a/desktop/src/components/TerminalPanel.tsx b/desktop/src/components/TerminalPanel.tsx index c23667b..44178d6 100644 --- a/desktop/src/components/TerminalPanel.tsx +++ b/desktop/src/components/TerminalPanel.tsx @@ -16,6 +16,7 @@ import { terminalTabsAtom, } from "../state/atoms"; import { useMachines } from "../lib/queries"; +import { useUiTheme } from "../lib/theme"; import { kindColor, shortId } from "../lib/format"; import TerminalPane from "./TerminalPane"; @@ -28,6 +29,7 @@ const MIN_H = 140; * with only the active one visible). Can expand to fullscreen. */ export default function TerminalPanel() { + const ui = useUiTheme(); const tabs = useAtomValue(terminalTabsAtom); const [active, setActive] = useAtom(activeTerminalAtom); const [fullscreen, setFullscreen] = useAtom(terminalFullscreenAtom); @@ -69,8 +71,8 @@ export default function TerminalPanel() { style={fullscreen ? undefined : { height }} className={`${collapsed ? "hidden " : ""}${ fullscreen - ? "absolute inset-0 z-30 flex flex-col bg-[#0a0d13]" - : "relative flex shrink-0 flex-col border-t border-white/10 bg-[#0a0d13]" + ? `absolute inset-0 z-30 flex flex-col ${ui.surface}` + : `relative flex shrink-0 flex-col border-t border-white/10 ${ui.surface}` }`} > {!fullscreen && ( diff --git a/desktop/src/components/ViewShell.tsx b/desktop/src/components/ViewShell.tsx index 0cf0dce..026377f 100644 --- a/desktop/src/components/ViewShell.tsx +++ b/desktop/src/components/ViewShell.tsx @@ -29,6 +29,7 @@ export function ViewShell({
( + "[data-filter-input] input, [data-filter-input]", + ); + if (el) { + e.preventDefault(); + el.focus(); + el.select(); + } + break; + } + // Cycle themes without reaching for the palette or the sidebar. + case "t": + case "T": + setTheme((t) => (t === "night-rider" ? "dark" : "night-rider")); + break; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [setPalette, setShortcuts, setRun]); + }, [setPalette, setShortcuts, setRun, setTheme]); } diff --git a/desktop/src/lib/theme.ts b/desktop/src/lib/theme.ts new file mode 100644 index 0000000..f6fb416 --- /dev/null +++ b/desktop/src/lib/theme.ts @@ -0,0 +1,117 @@ +import { useAtomValue } from "jotai"; +import { themeAtom } from "../state/atoms"; + +/** + * The one place per-theme colors live for the parts HeroUI cannot style: + * xterm terminals (a canvas — CSS variables never reach it) and the SVG + * content loaders. Everything else follows the theme through HeroUI's slots. + * + * The Night Rider values come from the VSCode theme's own JSON where it has + * them (backgrounds, cursor, selection). It defines no terminal palette, so + * the ANSI colors are derived from its token accents — the same pink, purple, + * blue, teal and yellow the editor colors use — rather than invented. + */ + +export interface UiTheme { + /** xterm theme for interactive terminals. */ + term: { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + }; + /** xterm theme for read-only log views (brighter foreground, no cursor). */ + logTerm: { + background: string; + foreground: string; + cursor: string; + selectionBackground: string; + }; + /** The class for opaque terminal-backed surfaces (panels, fullscreen). */ + surface: string; + /** react-content-loader shimmer colors. */ + skeleton: { bg: string; fg: string }; +} + +const dark: UiTheme = { + term: { + background: "#0a0d13", + foreground: "#dfe4ee", + cursor: "#7c8bff", + cursorAccent: "#0a0d13", + selectionBackground: "rgba(124,139,255,0.35)", + black: "#11151c", + red: "#ff6b6b", + green: "#7ee787", + yellow: "#f0c674", + blue: "#79b8ff", + magenta: "#c398ff", + cyan: "#66d9e8", + white: "#c9d1d9", + brightBlack: "#4b5262", + brightRed: "#ff8585", + brightGreen: "#a3f7b5", + brightYellow: "#ffd479", + }, + logTerm: { + background: "#0a0d13", + foreground: "#e8ecf5", + cursor: "#0a0d13", + selectionBackground: "rgba(124,139,255,0.35)", + }, + surface: "bg-[#0a0d13]", + skeleton: { bg: "#1b2130", fg: "#2b3446" }, +}; + +const nightRider: UiTheme = { + term: { + background: "#171530", + foreground: "#C9CBDB", + cursor: "#7EA7FB", + cursorAccent: "#171530", + selectionBackground: "rgba(93,64,137,0.55)", + black: "#1A1837", + red: "#FF709D", + green: "#55F0D7", + yellow: "#FFDB7F", + blue: "#7DA7FF", + magenta: "#e591ff", + cyan: "#71E4FE", + white: "#C9CBDB", + brightBlack: "#696292", + brightRed: "#ff8fb1", + brightGreen: "#8ff5e3", + brightYellow: "#ffe6a3", + }, + logTerm: { + background: "#171530", + foreground: "#DCDEF0", + cursor: "#171530", + selectionBackground: "rgba(93,64,137,0.55)", + }, + surface: "bg-[#171530]", + skeleton: { bg: "#26234E", fg: "#38356A" }, +}; + +export const UI_THEMES: Record<"dark" | "night-rider", UiTheme> = { + dark, + "night-rider": nightRider, +}; + +/** The active theme's non-CSS colors. */ +export function useUiTheme(): UiTheme { + return UI_THEMES[useAtomValue(themeAtom)]; +} diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 830e233..449904e 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -43,6 +43,25 @@ export default { extend: "dark", colors: { background: "#1e1c3f", + // The `default` ramp drives inputs, hovers and subtle borders in + // HeroUI — without restyling it those stay the old neutral gray + // and clash with the purple surfaces. A violet-tinted ramp built + // between the theme's own backgrounds and foregrounds. + default: { + 50: "#1A1837", + 100: "#222246", + 200: "#2D2B55", + 300: "#38356A", + 400: "#454180", + 500: "#5D5988", + 600: "#696292", + 700: "#8481B5", + 800: "#A5A2CC", + 900: "#C9CBDB", + DEFAULT: "#2D2B55", + foreground: "#C9CBDB", + }, + foreground: "#C9CBDB", content1: "#222246", content2: "#2D2B55", diff --git a/web/src/components/AgentPanel.tsx b/web/src/components/AgentPanel.tsx index deae4df..6b25a0e 100644 --- a/web/src/components/AgentPanel.tsx +++ b/web/src/components/AgentPanel.tsx @@ -38,6 +38,7 @@ import { } from "../lib/queries"; import { useToast } from "../state/toast"; import { HAS_NATIVE_FOLDER_PICKER, pickWorkspace } from "../lib/api"; +import { useUiTheme } from "../lib/theme"; import TerminalPane from "./TerminalPane"; import AgentSessionPicker from "./AgentSessionPicker"; import AgentPromptModal from "./AgentPromptModal"; @@ -57,6 +58,7 @@ const MIN_W = 340; * hidden behind a spinner (see `useStartAgent`). */ export default function AgentPanel() { + const ui = useUiTheme(); const [open, setOpen] = useAtom(agentPanelOpenAtom); const [fullscreen, setFullscreen] = useAtom(agentPanelFullscreenAtom); const [width, setWidth] = useAtom(agentPanelWidthAtom); @@ -170,8 +172,8 @@ export default function AgentPanel() { style={fullscreen ? undefined : { width }} className={`${!open ? "hidden " : ""}${ fullscreen - ? "absolute inset-0 z-30 flex flex-col bg-[#0a0d13]" - : "relative flex shrink-0 flex-col border-l border-white/10 bg-[#0a0d13]" + ? `absolute inset-0 z-30 flex flex-col ${ui.surface}` + : `relative flex shrink-0 flex-col border-l border-white/10 ${ui.surface}` }`} > {!fullscreen && ( diff --git a/web/src/components/LogsPane.tsx b/web/src/components/LogsPane.tsx index e98238a..4f3e6a8 100644 --- a/web/src/components/LogsPane.tsx +++ b/web/src/components/LogsPane.tsx @@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { registerWebLinks } from "../lib/xtermLinks"; +import { useUiTheme } from "../lib/theme"; import { Button, Input, Switch, Tooltip } from "@heroui/react"; import { IconSearch, @@ -15,19 +16,16 @@ import { api, onLogLine } from "../lib/api"; import type { UnlistenFn } from "../lib/api"; import { useToast } from "../state/toast"; -const THEME = { - background: "#0a0d13", - // Near-white rather than the muted grey the rest of the chrome uses: this is - // the content, not a label, and #c6ccd8 on #0a0d13 reads as dim next to it. - foreground: "#e8ecf5", - cursor: "#0a0d13", - selectionBackground: "rgba(124,139,255,0.35)", -}; /** Read-only console log viewer: initial snapshot + live `logs -f` follow, an * in-buffer quick search, and copy-on-select. Toggle to the boot log for * early-failure diagnostics. */ export default function LogsPane({ machineId }: { machineId: string }) { + const ui = useUiTheme(); + + useEffect(() => { + if (termRef.current) termRef.current.options.theme = ui.logTerm; + }, [ui]); const hostRef = useRef(null); const termRef = useRef(null); const searchRef = useRef(null); @@ -78,7 +76,7 @@ export default function LogsPane({ machineId }: { machineId: string }) { cursorInactiveStyle: "none", convertEol: true, scrollback: 20000, - theme: THEME, + theme: ui.logTerm, }); fit = new FitAddon(); const search = new SearchAddon(); diff --git a/web/src/components/MachineDetail.tsx b/web/src/components/MachineDetail.tsx index 2153759..9d959c8 100644 --- a/web/src/components/MachineDetail.tsx +++ b/web/src/components/MachineDetail.tsx @@ -43,6 +43,7 @@ import { useStopMachine, } from "../lib/queries"; import { ago, exitLabel, isUnikraft, kindColor, shortId } from "../lib/format"; +import { useUiTheme } from "../lib/theme"; import { useToast } from "../state/toast"; import SnapshotsPane from "./SnapshotsPane"; import TerminalPane from "./TerminalPane"; @@ -115,6 +116,7 @@ function Inspect({ m }: { m: Machine }) { } export default function MachineDetail() { + const ui = useUiTheme(); const [selected, setSelected] = useAtom(selectedMachineAtom); const { data: machines = [] } = useMachines(); const stopMutation = useStopMachine(); @@ -414,12 +416,12 @@ export default function MachineDetail() {
{tab === "terminal" && m.running && !isUnikraft(m.kind) && ( -
+
)} {tab === "logs" && ( -
+
)} diff --git a/web/src/components/SettingsModal.tsx b/web/src/components/SettingsModal.tsx index f752415..bb9c856 100644 --- a/web/src/components/SettingsModal.tsx +++ b/web/src/components/SettingsModal.tsx @@ -18,7 +18,9 @@ import { IconAlertTriangle, IconPlugConnected, } from "@tabler/icons-react"; -import { settingsOpenAtom } from "../state/atoms"; +import { settingsOpenAtom, + themeAtom, +} from "../state/atoms"; import { useProbe, useSaveSettings, useSettings } from "../lib/queries"; import { useToast } from "../state/toast"; import { clearConnection, DEFAULT_URL } from "../lib/connection"; @@ -33,6 +35,7 @@ const schema = z.object({ type FormValues = z.infer; export default function SettingsModal() { + const [theme, setTheme] = useAtom(themeAtom); const [open, setOpen] = useAtom(settingsOpenAtom); const { data: settings } = useSettings(); const { data: probe, refetch: refetchProbe, isFetching: checking } = useProbe(); @@ -87,6 +90,34 @@ export default function SettingsModal() { Settings + {/* Appearance first: it is the setting people come looking for. */} +
+ +
+ {( + [ + ["night-rider", "Night Rider"], + ["dark", "Classic Dark"], + ] as const + ).map(([key, label]) => ( + + ))} +
+

+ Also: the Appearance button in the sidebar, the command palette, or the{" "} + t key. +

+
+
       {text}
     
); diff --git a/web/src/components/ShortcutsModal.tsx b/web/src/components/ShortcutsModal.tsx index 22a1b3f..b636c05 100644 --- a/web/src/components/ShortcutsModal.tsx +++ b/web/src/components/ShortcutsModal.tsx @@ -17,6 +17,8 @@ const GROUPS: { title: string; items: [string[], string][] }[] = [ [["⌘", "K"], "Open command palette"], [["?"], "Show this help"], [["R"], "Refresh"], + [["F"], "Focus the filter box"], + [["T"], "Cycle theme (Night Rider / Classic Dark)"], [["Esc"], "Close dialog / palette"], ], }, diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index cf66ef2..b0a2d66 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { IconNetwork, IconCamera, IconBrandDocker, + IconMoon, IconSettings, IconKeyboard, IconCloud, @@ -17,6 +18,7 @@ import { Tooltip } from "@heroui/react"; import { settingsOpenAtom, shortcutsOpenAtom, + themeAtom, viewAtom, } from "../state/atoms"; import { @@ -64,6 +66,7 @@ export default function Sidebar() { true, !!dockerStatus?.running, ); + const [theme, setTheme] = useAtom(themeAtom); const [, setSettingsOpen] = useAtom(settingsOpenAtom); const [, setShortcutsOpen] = useAtom(shortcutsOpenAtom); @@ -95,7 +98,7 @@ export default function Sidebar() { className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition ${ active ? "bg-primary/15 text-foreground shadow-[inset_0_0_0_1px] shadow-primary/25" - : "text-foreground-500 hover:bg-white/5 hover:text-foreground-300" + : "text-foreground-500 hover:bg-default-100/70 hover:text-foreground-300" }`} > {counts[it.key]} @@ -122,10 +125,28 @@ export default function Sidebar() {
+ + + diff --git a/web/src/components/Skeletons.tsx b/web/src/components/Skeletons.tsx index 3f2ab4f..f2facb4 100644 --- a/web/src/components/Skeletons.tsx +++ b/web/src/components/Skeletons.tsx @@ -1,10 +1,11 @@ import ContentLoader from "react-content-loader"; - -const BG = "#1b2130"; -const FG = "#2b3446"; +import { useUiTheme } from "../lib/theme"; /** One shimmering table row (scales to the container width via viewBox). */ function RowSkeleton() { + // Themed, not constant: a gray shimmer over a purple surface reads as a + // rendering glitch rather than as loading. + const { skeleton } = useUiTheme(); return ( @@ -29,6 +30,7 @@ function RowSkeleton() { /** A placeholder table shown while a list query loads for the first time. */ export function TableSkeleton({ rows = 6 }: { rows?: number }) { + const { skeleton } = useUiTheme(); return (
{/* Header shimmer */} @@ -38,8 +40,8 @@ export function TableSkeleton({ rows = 6 }: { rows?: number }) { height={34} viewBox="0 0 1000 34" preserveAspectRatio="none" - backgroundColor={BG} - foregroundColor={FG} + backgroundColor={skeleton.bg} + foregroundColor={skeleton.fg} className="border-b border-white/10" > @@ -57,6 +59,7 @@ export function TableSkeleton({ rows = 6 }: { rows?: number }) { /** One shimmering flavor card. */ function CardSkeleton() { + const { skeleton } = useUiTheme(); return ( @@ -96,6 +99,7 @@ export function CardGridSkeleton({ cards = 8 }: { cards?: number }) { /** A small inline block skeleton (e.g. for panels / cards). */ export function BlockSkeleton({ height = 120 }: { height?: number }) { + const { skeleton } = useUiTheme(); return ( diff --git a/web/src/components/TerminalPane.tsx b/web/src/components/TerminalPane.tsx index b4b741f..f2d1605 100644 --- a/web/src/components/TerminalPane.tsx +++ b/web/src/components/TerminalPane.tsx @@ -2,33 +2,11 @@ import { useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { registerWebLinks } from "../lib/xtermLinks"; +import { useUiTheme } from "../lib/theme"; import { api, onTermData, onTermExit } from "../lib/api"; import type { UnlistenFn } from "../lib/api"; import { HOST_MACHINE } from "../state/atoms"; -const THEME = { - background: "#0a0d13", - foreground: "#dfe4ee", - cursor: "#7c8bff", - cursorAccent: "#0a0d13", - selectionBackground: "rgba(124,139,255,0.35)", - black: "#11151c", - red: "#ff6b6b", - green: "#7ee787", - yellow: "#f0c674", - blue: "#79b8ff", - magenta: "#c398ff", - cyan: "#66d9e8", - white: "#c9d1d9", - brightBlack: "#4b5262", - brightRed: "#ff8585", - brightGreen: "#a3f7b5", - brightYellow: "#ffd479", - brightBlue: "#a5c8ff", - brightMagenta: "#d7b6ff", - brightCyan: "#8ce8f0", - brightWhite: "#f0f6fc", -}; /** An interactive PTY session (`bsdkrun exec -t `), streamed over * Tauri events into an xterm.js instance. */ @@ -39,6 +17,14 @@ export default function TerminalPane({ machineId: string; command?: string[]; }) { + const ui = useUiTheme(); + + // The live Terminal, for re-theming: xterm is a canvas, so CSS variables + // never reach it and the theme has to be pushed in. + const termInst = useRef(null); + useEffect(() => { + if (termInst.current) termInst.current.options.theme = ui.term; + }, [ui]); const hostRef = useRef(null); const [status, setStatus] = useState<"connecting" | "open" | "closed">( "connecting", @@ -70,7 +56,7 @@ export default function TerminalPane({ cursorBlink: true, cursorStyle: "bar", scrollback: 5000, - theme: THEME, + theme: ui.term, allowProposedApi: true, }); fit = new FitAddon(); @@ -81,6 +67,7 @@ export default function TerminalPane({ window.open(uri, "_blank", "noopener,noreferrer"); }); term.open(hostRef.current); + termInst.current = term; fit.fit(); term.onData((data) => { diff --git a/web/src/components/TerminalPanel.tsx b/web/src/components/TerminalPanel.tsx index c23667b..44178d6 100644 --- a/web/src/components/TerminalPanel.tsx +++ b/web/src/components/TerminalPanel.tsx @@ -16,6 +16,7 @@ import { terminalTabsAtom, } from "../state/atoms"; import { useMachines } from "../lib/queries"; +import { useUiTheme } from "../lib/theme"; import { kindColor, shortId } from "../lib/format"; import TerminalPane from "./TerminalPane"; @@ -28,6 +29,7 @@ const MIN_H = 140; * with only the active one visible). Can expand to fullscreen. */ export default function TerminalPanel() { + const ui = useUiTheme(); const tabs = useAtomValue(terminalTabsAtom); const [active, setActive] = useAtom(activeTerminalAtom); const [fullscreen, setFullscreen] = useAtom(terminalFullscreenAtom); @@ -69,8 +71,8 @@ export default function TerminalPanel() { style={fullscreen ? undefined : { height }} className={`${collapsed ? "hidden " : ""}${ fullscreen - ? "absolute inset-0 z-30 flex flex-col bg-[#0a0d13]" - : "relative flex shrink-0 flex-col border-t border-white/10 bg-[#0a0d13]" + ? `absolute inset-0 z-30 flex flex-col ${ui.surface}` + : `relative flex shrink-0 flex-col border-t border-white/10 ${ui.surface}` }`} > {!fullscreen && ( diff --git a/web/src/components/ViewShell.tsx b/web/src/components/ViewShell.tsx index 0cf0dce..026377f 100644 --- a/web/src/components/ViewShell.tsx +++ b/web/src/components/ViewShell.tsx @@ -29,6 +29,7 @@ export function ViewShell({
( + "[data-filter-input] input, [data-filter-input]", + ); + if (el) { + e.preventDefault(); + el.focus(); + el.select(); + } + break; + } + // Cycle themes without reaching for the palette or the sidebar. + case "t": + case "T": + setTheme((t) => (t === "night-rider" ? "dark" : "night-rider")); + break; } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [setPalette, setShortcuts, setRun]); + }, [setPalette, setShortcuts, setRun, setTheme]); } diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts new file mode 100644 index 0000000..f6fb416 --- /dev/null +++ b/web/src/lib/theme.ts @@ -0,0 +1,117 @@ +import { useAtomValue } from "jotai"; +import { themeAtom } from "../state/atoms"; + +/** + * The one place per-theme colors live for the parts HeroUI cannot style: + * xterm terminals (a canvas — CSS variables never reach it) and the SVG + * content loaders. Everything else follows the theme through HeroUI's slots. + * + * The Night Rider values come from the VSCode theme's own JSON where it has + * them (backgrounds, cursor, selection). It defines no terminal palette, so + * the ANSI colors are derived from its token accents — the same pink, purple, + * blue, teal and yellow the editor colors use — rather than invented. + */ + +export interface UiTheme { + /** xterm theme for interactive terminals. */ + term: { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + }; + /** xterm theme for read-only log views (brighter foreground, no cursor). */ + logTerm: { + background: string; + foreground: string; + cursor: string; + selectionBackground: string; + }; + /** The class for opaque terminal-backed surfaces (panels, fullscreen). */ + surface: string; + /** react-content-loader shimmer colors. */ + skeleton: { bg: string; fg: string }; +} + +const dark: UiTheme = { + term: { + background: "#0a0d13", + foreground: "#dfe4ee", + cursor: "#7c8bff", + cursorAccent: "#0a0d13", + selectionBackground: "rgba(124,139,255,0.35)", + black: "#11151c", + red: "#ff6b6b", + green: "#7ee787", + yellow: "#f0c674", + blue: "#79b8ff", + magenta: "#c398ff", + cyan: "#66d9e8", + white: "#c9d1d9", + brightBlack: "#4b5262", + brightRed: "#ff8585", + brightGreen: "#a3f7b5", + brightYellow: "#ffd479", + }, + logTerm: { + background: "#0a0d13", + foreground: "#e8ecf5", + cursor: "#0a0d13", + selectionBackground: "rgba(124,139,255,0.35)", + }, + surface: "bg-[#0a0d13]", + skeleton: { bg: "#1b2130", fg: "#2b3446" }, +}; + +const nightRider: UiTheme = { + term: { + background: "#171530", + foreground: "#C9CBDB", + cursor: "#7EA7FB", + cursorAccent: "#171530", + selectionBackground: "rgba(93,64,137,0.55)", + black: "#1A1837", + red: "#FF709D", + green: "#55F0D7", + yellow: "#FFDB7F", + blue: "#7DA7FF", + magenta: "#e591ff", + cyan: "#71E4FE", + white: "#C9CBDB", + brightBlack: "#696292", + brightRed: "#ff8fb1", + brightGreen: "#8ff5e3", + brightYellow: "#ffe6a3", + }, + logTerm: { + background: "#171530", + foreground: "#DCDEF0", + cursor: "#171530", + selectionBackground: "rgba(93,64,137,0.55)", + }, + surface: "bg-[#171530]", + skeleton: { bg: "#26234E", fg: "#38356A" }, +}; + +export const UI_THEMES: Record<"dark" | "night-rider", UiTheme> = { + dark, + "night-rider": nightRider, +}; + +/** The active theme's non-CSS colors. */ +export function useUiTheme(): UiTheme { + return UI_THEMES[useAtomValue(themeAtom)]; +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 830e233..449904e 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -43,6 +43,25 @@ export default { extend: "dark", colors: { background: "#1e1c3f", + // The `default` ramp drives inputs, hovers and subtle borders in + // HeroUI — without restyling it those stay the old neutral gray + // and clash with the purple surfaces. A violet-tinted ramp built + // between the theme's own backgrounds and foregrounds. + default: { + 50: "#1A1837", + 100: "#222246", + 200: "#2D2B55", + 300: "#38356A", + 400: "#454180", + 500: "#5D5988", + 600: "#696292", + 700: "#8481B5", + 800: "#A5A2CC", + 900: "#C9CBDB", + DEFAULT: "#2D2B55", + foreground: "#C9CBDB", + }, + foreground: "#C9CBDB", content1: "#222246", content2: "#2D2B55",