feat(frontend): add a terminal theme and three-way theme switcher

Adds the cyber theme to the console and login page, replaces the dark/light toggle with a theme switcher, and moves the login page script to /login.js so it is served under the existing CSP.
This commit is contained in:
Zakaria
2026-10-23 10:12:41 -04:00
parent 502d0b3986
commit ba90eff5db
8 changed files with 200 additions and 21 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
var t = localStorage.getItem("floci-theme");
document.documentElement.setAttribute(
"data-theme",
t === "light" ? "light" : "dark",
t === "light" || t === "cyber" ? t : "dark",
);
})();
</script>
+7 -12
View File
@@ -1,5 +1,5 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react'
import theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme'
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
export function Layout() {
const location = useLocation()
const activeCloud = activeCloudFromPath(location.pathname)
const {theme, toggle} = useTheme()
const {theme, setTheme} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
@@ -209,15 +208,11 @@ export function Layout() {
<div className="shell">
<header className="topbar">
<TopbarSearch/>
<button
className="icon-btn"
type="button"
onClick={toggle}
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
>
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
</button>
<div className="theme-switcher" role="group" aria-label="Console theme">
<button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
<button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
<button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
</div>
<div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/>
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
+72
View File
@@ -218,6 +218,58 @@
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
}
[data-theme="cyber"] {
color-scheme: dark;
--bg: #02060a;
--surface: #050d10;
--elevated: #071216;
--raised: #0a191d;
--raised-hover: #10252a;
--border: rgba(78, 255, 150, 0.28);
--border-faint: rgba(78, 255, 150, 0.16);
--border-lighter: rgba(78, 255, 150, 0.08);
--border-hover: #ffea00;
--active-nav-bg: rgba(78, 255, 150, 0.14);
--text: #8affb8;
--text-bright: #d6ffe6;
--text-2: #62d992;
--text-3: #2f9a68;
--text-muted: var(--text-3);
--text-nav: #8affb8;
--status-ok: #4eff96;
--status-warn: #ffea00;
--status-error: #ff8da1;
--accent: #4eff96;
--accent-tint: rgba(78, 255, 150, 0.14);
--accent-tint-faint: rgba(78, 255, 150, 0.07);
--accent-tint-border: rgba(78, 255, 150, 0.34);
--link: #ffea00;
--link-tint: rgba(255, 234, 0, 0.08);
--link-tint-hover: rgba(255, 234, 0, 0.14);
--link-tint-border: rgba(255, 234, 0, 0.3);
--hover: rgba(78, 255, 150, 0.06);
--hover-md: rgba(78, 255, 150, 0.1);
--hover-lg: rgba(78, 255, 150, 0.14);
--input-border: rgba(78, 255, 150, 0.42);
--input-bg: #030b0d;
--connection-bg: #02090c;
--kbd-border: rgba(78, 255, 150, 0.35);
--scrollbar-thumb: #26784e;
--service-card-bg: #050d10;
--service-card-hover: #071216;
--fn-card-hover: #0a191d;
--bulk-bar-bg: #071b13;
--bulk-bar-border: #4eff96;
--bulk-bar-text: #d6ffe6;
--code-bg: #000;
}
* {
box-sizing: border-box;
}
@@ -626,6 +678,26 @@ pre,
border-bottom: 1px solid var(--border);
}
.theme-switcher {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--connection-bg);
}
.theme-switcher .icon-btn {
width: 26px;
height: 26px;
}
.theme-switcher .icon-btn.active {
border-color: var(--accent);
background: var(--accent);
color: var(--bg);
}
.search {
position: relative;
width: min(480px, 100%);
+3 -5
View File
@@ -1,10 +1,10 @@
import {useEffect, useState} from 'react'
type Theme = 'dark' | 'light'
export type Theme = 'dark' | 'light' | 'cyber'
function getStored(): Theme {
const v = localStorage.getItem('floci-theme')
return v === 'light' ? 'light' : 'dark'
return v === 'light' || v === 'cyber' ? v : 'dark'
}
export function useTheme() {
@@ -15,7 +15,5 @@ export function useTheme() {
localStorage.setItem('floci-theme', theme)
}, [theme])
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
return {theme, toggle}
return {theme, setTheme}
}