From 3ca44a2eb7f70d9c46affe227cf15ddd6214bb46 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Tue, 18 Aug 2026 14:50:20 +0300 Subject: [PATCH] fix(desktop,web): make every surface follow the theme, and make switching findable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Night Rider commit themed HeroUI's slots and nothing else, which the first click exposed: terminals stayed on the old palette, the content loaders shimmered gray over purple, inputs hovered in neutral gray, and nothing on screen said where themes even lived. One module now owns what CSS cannot reach — `lib/theme.ts`, per-theme values for xterm (a canvas; CSS variables never arrive) and the SVG content loaders. Night Rider's terminal palette is derived from the theme's own token accents (it defines no terminal colors of its own), and a live terminal re-themes via `term.options.theme` when the atom flips, not just on next mount. - Terminal panes, the bottom terminal panel, the agent panel and log views take their background and palette from the active theme; the hardcoded `#0a0d13` surfaces are gone. - Skeletons (react-content-loader) shimmer in theme colors. - HeroUI's `default` ramp is themed — it drives input hover/focus and subtle borders, which otherwise stayed neutral gray against purple. - Sidebar hovers use the themed ramp instead of a white tint. Switching is now findable four ways, because "I don't see where to change theme" is a bug report about all of them missing: an **Appearance** entry at the bottom of the sidebar, an **Appearance** section first in Settings, the command palette entry, and the **t** key cycling themes. **f** focuses the current view's filter box (vim/GitHub muscle memory); both keys are in the shortcuts modal. Verified on aarch64-darwin: tsc in both trees, vite build in web/. Not verified visually across every screen. --- desktop/src/components/AgentPanel.tsx | 6 +- desktop/src/components/LogsPane.tsx | 16 ++- desktop/src/components/MachineDetail.tsx | 6 +- desktop/src/components/SettingsModal.tsx | 33 +++++- desktop/src/components/SetupPane.tsx | 4 +- desktop/src/components/ShortcutsModal.tsx | 2 + desktop/src/components/Sidebar.tsx | 31 +++++- desktop/src/components/Skeletons.tsx | 26 +++-- desktop/src/components/TerminalPane.tsx | 35 ++----- desktop/src/components/TerminalPanel.tsx | 6 +- desktop/src/components/ViewShell.tsx | 1 + desktop/src/hooks/useShortcuts.ts | 24 ++++- desktop/src/lib/theme.ts | 117 ++++++++++++++++++++++ desktop/tailwind.config.js | 19 ++++ web/src/components/AgentPanel.tsx | 6 +- web/src/components/LogsPane.tsx | 16 ++- web/src/components/MachineDetail.tsx | 6 +- web/src/components/SettingsModal.tsx | 33 +++++- web/src/components/SetupPane.tsx | 4 +- web/src/components/ShortcutsModal.tsx | 2 + web/src/components/Sidebar.tsx | 31 +++++- web/src/components/Skeletons.tsx | 26 +++-- web/src/components/TerminalPane.tsx | 35 ++----- web/src/components/TerminalPanel.tsx | 6 +- web/src/components/ViewShell.tsx | 1 + web/src/hooks/useShortcuts.ts | 24 ++++- web/src/lib/theme.ts | 117 ++++++++++++++++++++++ web/tailwind.config.js | 19 ++++ 28 files changed, 536 insertions(+), 116 deletions(-) create mode 100644 desktop/src/lib/theme.ts create mode 100644 web/src/lib/theme.ts 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", -- 2.51.2