diff --git a/PLAN.md b/PLAN.md index e3351b3..2e21461 100644 --- a/PLAN.md +++ b/PLAN.md @@ -220,6 +220,13 @@ something long in one, switch away and back, refresh — it's all still there. - **Terminal extras** — search, copy-mode, font/theme, split panes, upload via drag-drop into cwd. +- **Settings + themes** — **shipped 2026-09-18** (`Settings.tsx`, `themes.ts`): Settings + page with Appearance (Dark, Light, Cyberpunk, Retro — CSS variables on `` plus a + matching xterm palette, applied before first paint from localStorage and synced hub-side + via `GET/PUT /api/prefs/ui` so the choice follows the login), Account (change password, + `POST /api/password`, current password required), Access tokens (list/revoke the tokens + hubs hold), About (version, host, arch, uptime, certificate). + ## Open questions - Idle-session reaping policy (never / after N days) — leaning never; it's a diff --git a/internal/api/server.go b/internal/api/server.go index 20a12c6..673d6ab 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -143,6 +143,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot)) m.HandleFunc("GET /api/procs", s.auth(s.procList)) m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal)) + m.HandleFunc("POST /api/password", s.auth(s.changePassword)) + m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs)) + m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs)) m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) @@ -1190,6 +1193,61 @@ func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// ---- account ---- + +func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { + var body struct{ Current, Password string } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) { + jsonErr(w, 403, "current password is wrong") + return + } + if len(body.Password) < 12 { + jsonErr(w, 400, "password must be at least 12 characters") + return + } + h, err := auth.HashPassword(body.Password) + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + log.Printf("password changed (from %s)", clientIP(r)) + w.WriteHeader(204) +} + +// ---- ui prefs (theme) ---- + +func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) { + var raw json.RawMessage + s.store.View(func(st *store.State) { raw = st.UI }) + if len(raw) == 0 { + raw = json.RawMessage("{}") + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(raw) +} + +func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(io.LimitReader(r.Body, 16<<10)) + if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' { + jsonErr(w, 400, "expected a JSON object") + return + } + if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil { + jsonErr(w, 500, err.Error()) + return + } + w.WriteHeader(204) +} + // ---- dashboard layout prefs (opaque JSON object, owned by the UI) ---- func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/store/store.go b/internal/store/store.go index 62d0a61..62a626f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -19,6 +19,7 @@ type State struct { Terminals []Terminal `json:"terminals"` Sessions map[string]time.Time `json:"sessions"` // token -> expiry Dashboard json.RawMessage `json:"dashboard,omitempty"` // UI layout prefs, opaque to the server + UI json.RawMessage `json:"ui,omitempty"` // theme etc., opaque to the server } type Store struct { diff --git a/web/src/App.tsx b/web/src/App.tsx index 1df3df8..911f5f4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,12 +2,16 @@ import { useCallback, useEffect, useState } from 'react' import { api, Me, Unauthenticated } from './api' import Login from './Login' import Shell from './Shell' +import { applyTheme, currentThemeId } from './themes' export default function App() { const [me, setMe] = useState(undefined) const refresh = useCallback(async () => { - try { setMe(await api.meHere()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) } + try { + const m = await api.meHere(); setMe(m) + api.uiPrefs().then((p) => { if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) }).catch(() => { /* keep local */ }) + } catch (e) { if (e instanceof Unauthenticated) setMe(null) } }, []) useEffect(() => { refresh() }, [refresh]) diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx new file mode 100644 index 0000000..cb12848 --- /dev/null +++ b/web/src/Settings.tsx @@ -0,0 +1,107 @@ +import { FormEvent, useCallback, useEffect, useState } from 'react' +import { api, Me } from './api' +import { applyTheme, currentThemeId, THEMES } from './themes' +import { Btn, Loading, toast } from './ui' + +const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` } + +// Small painted preview of a theme: rail, header, a tile and a bar, in its own colours. +function Preview({ vars }: { vars: Record }) { + const v = (k: string) => vars[k] + return ( +
+
Ham Lab
+
+
+ +
+
+
+ CPU12% +
+
+
$ _
+
+
+
+ ) +} + +export default function Settings({ me }: { me: Me }) { + const [theme, setTheme] = useState(currentThemeId()) + const [tokens, setTokens] = useState<{ name: string; created: string }[] | null>(null) + const [cur, setCur] = useState('') + const [pw1, setPw1] = useState('') + const [pw2, setPw2] = useState('') + const [pwErr, setPwErr] = useState('') + + const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, []) + useEffect(() => { loadTokens() }, [loadTokens]) + + const pick = async (id: string) => { + applyTheme(id); setTheme(id) + try { await api.saveUIPrefs({ theme: id }) } catch { /* local still applied */ } + } + + async function changePassword(e: FormEvent) { + e.preventDefault() + setPwErr('') + if (pw1 !== pw2) { setPwErr('New passwords differ.'); return } + if (pw1.length < 12) { setPwErr('At least 12 characters.'); return } + try { await api.changePassword(cur, pw1); toast('password changed'); setCur(''); setPw1(''); setPw2('') } + catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') } + } + async function revoke(name: string) { + if (!window.confirm(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return + try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') } + } + + return ( +
+

Appearance

follows you to every device you sign in on
+
+ {THEMES.map((t) => ( + + ))} +
+ +

Account · {me.host}

+
+ + + + {pwErr &&
{pwErr}
} +
Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.
+
+ +

Access tokens

+
+
Tokens let another Ham Lab (a hub) manage this host. They're issued when a hub adds this machine and revoked when it removes it.
+ {tokens === null && } + {tokens && tokens.length === 0 &&
No tokens. No hub manages this host.
} + {tokens && tokens.map((t) => ( +
+ {t.name} + issued {new Date(t.created).toLocaleString()} + revoke(t.name)}>Revoke +
+ ))} +
+ +

About

+
+
+
Version{me.version}
+
Host{me.host} · {me.arch}
+
Runs as{me.user}
+
Uptime{fmtUptime(me.uptime)}
+ {me.fingerprint &&
Certificate{me.fingerprint}
} +
+
+
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index ece8160..79a404c 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -11,6 +11,7 @@ import Disks from './Disks' import Files from './Files' import Procs from './Procs' import Machines from './Machines' +import Settings from './Settings' import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' import Power from './Power' @@ -27,15 +28,16 @@ const GROUPS: Group[] = [ { id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, { id: 'procs', label: 'Processes', icon: '≡', page: true }, { id: 'terminal', label: 'Terminal', icon: '>_' }, - { id: 'services', label: 'Services', icon: '⚙', page: true }, + { id: 'services', label: 'Services', icon: '◈', page: true }, { id: 'cron', label: 'Cron', icon: '⏱', page: true }, { id: 'updates', label: 'Updates', icon: '⇪', page: true }, { id: 'disks', label: 'Disks', icon: '▤', page: true }, { id: 'files', label: 'Files', icon: FolderIcon, page: true }, { id: 'machines', label: 'Machines', icon: '⌂', page: true }, + { id: 'settings', label: 'Settings', icon: '⚙', page: true }, ] -type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' +type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' function fmtUptime(s: number): string { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) @@ -150,6 +152,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
setDrawer(false)} />
+ {view === 'settings' && } {view === 'machines' && { switchMachine(n); setView('dashboard') }} />} {view === 'dashboard' && } {view === 'procs' && } @@ -180,7 +183,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx index 1c7ccd0..ca51f8a 100644 --- a/web/src/TerminalPane.tsx +++ b/web/src/TerminalPane.tsx @@ -14,13 +14,7 @@ interface Props { onExit: () => void } -const THEME = { - background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78', - black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', - magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198', - brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', - brightCyan: '#56d4dd', brightWhite: '#f0f6fc', -} +import { currentThemeId, themeById } from './themes' export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) { const box = useRef(null) @@ -34,7 +28,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }: useEffect(() => { const el = box.current! const term = new Terminal({ - theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + theme: themeById(currentThemeId()).xterm, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14, cursorBlink: true, scrollback: 5000, allowProposedApi: true, }) @@ -106,6 +100,9 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }: const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } } document.addEventListener('visibilitychange', onVis) + const onTheme = (e: Event) => { term.options.theme = (e as CustomEvent).detail.xterm } + window.addEventListener('hamlab:theme', onTheme) + register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } }) return () => { @@ -113,6 +110,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }: closed = true clearTimeout(retryTimer) document.removeEventListener('visibilitychange', onVis) + window.removeEventListener('hamlab:theme', onTheme) ro.disconnect() ws?.close() term.dispose() diff --git a/web/src/api.ts b/web/src/api.ts index abcc225..4e47640 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -165,6 +165,11 @@ export const api = { addMachine: (name: string, url: string, password: string) => req('POST', '/api/nodes', { name, url, password }, true), updateMachine: (name: string) => req('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true), updateAllMachines: () => req('POST', '/api/machines-update-all', undefined, true), + changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), + tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true), + revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), + uiPrefs: () => req<{ theme?: string }>('GET', '/api/prefs/ui', undefined, true), + saveUIPrefs: (p: { theme?: string }) => req('PUT', '/api/prefs/ui', p, true), removeMachine: (name: string) => req('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true), terminals: () => req('GET', '/api/terminals'), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), diff --git a/web/src/main.tsx b/web/src/main.tsx index 5d18c6e..e39804a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,8 @@ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' +import { applyTheme, currentThemeId } from './themes' +applyTheme(currentThemeId()) import '@xterm/xterm/css/xterm.css' import './styles.css' diff --git a/web/src/styles.css b/web/src/styles.css index d1dd2b9..9b44063 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3,11 +3,12 @@ --fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff; --ok: #3fb950; --bad: #ff7b72; --s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18); + --btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; --header-h: 44px; --footer-h: 28px; --rail-w: 220px; } * { box-sizing: border-box; } html, body, #root { height: 100%; margin: 0; } -body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; } +body { background: var(--bg); color: var(--fg); font: 14px/1.4 var(--font); overscroll-behavior: none; transition: background .2s, color .2s; } button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; } .spacer { flex: 1; } .muted { color: var(--muted); } @@ -18,7 +19,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .login-logo { width: 160px; height: 160px; align-self: center; border-radius: 50%; margin-bottom: 4px; } .login h1 { text-align: center; margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; } .login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } -.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; } +.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; } .login button:disabled { opacity: .5; } .error { color: var(--bad); font-size: 13px; } @@ -57,7 +58,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .sdot.run { background: var(--ok); } /* main + terminal */ -.main { flex: 1; min-width: 0; position: relative; background: #0d1117; } +.main { flex: 1; min-width: 0; position: relative; background: var(--term-bg); } .term { position: absolute; inset: 0; padding: 6px 0 0 6px; } .term-inner { height: 100%; } .term .xterm { height: 100%; } @@ -66,7 +67,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi /* key bar (touch devices / narrow screens) */ .keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; } .keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; } -.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); } +.keybar button.on { background: var(--accent); color: var(--btn-fg); border-color: var(--accent); } @media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } } /* mobile: rail becomes a drawer */ @@ -114,7 +115,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .dash-bar .hint { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .dash-bar select.btn { padding: 3px 6px; } .btn { padding: 5px 10px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-size: 12px; color: var(--fg); } -.btn.on { background: var(--accent); color: #111; border-color: var(--accent); } +.btn.on { background: var(--accent); color: var(--btn-fg); border-color: var(--accent); } .mod-tools { display: flex; gap: 4px; margin: -4px -4px 6px auto; justify-content: flex-end; } .mod-tools button { padding: 2px 7px; background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; font-size: 12px; color: var(--muted); } .mod-tools button:hover:not(:disabled) { color: var(--fg); } .mod-tools button:disabled { opacity: .35; cursor: default; } @@ -150,8 +151,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); } .tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; } .tabs button.on { color: var(--fg); border-bottom-color: var(--accent); } -.unit-file { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; } -.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: #0d1117; overflow: hidden; } +.unit-file { margin: 0; padding: 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; } +.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--term-bg); overflow: hidden; } .logs-bar { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; background: var(--bg2); } .log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; } @@ -177,8 +178,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .cron-detail .back { display: none; align-self: flex-start; } .cron-detail h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, monospace; word-break: break-all; white-space: pre-wrap; } .notice { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--muted); background: var(--bg2); } -.editor { width: 100%; min-height: 260px; padding: 10px; background: #0d1117; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; } -.run-out { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; } +.editor { width: 100%; min-height: 260px; padding: 10px; background: var(--term-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; } +.run-out { margin: 0; padding: 10px; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; } @media (max-width: 800px) { .cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; } .cron-detail .back { display: inline-block; } @@ -325,7 +326,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .phead { background: var(--bg2); cursor: default; padding: 0 10px; border-top: 1px solid var(--border); } .th { padding: 6px 0; text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .th.num { text-align: right; } .th.on { color: var(--fg); } .pdetail { position: sticky; bottom: 0; z-index: 3; border-top: 1px solid var(--border); background: var(--bg2); padding: 10px; display: flex; flex-direction: column; gap: 8px; box-shadow: 0 -6px 16px rgba(0,0,0,.4); } -.cmdline { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: var(--fg); background: #0d1117; padding: 8px; border-radius: 6px; border: 1px solid var(--border); max-height: 120px; overflow: auto; } +.cmdline { font-size: 12px; word-break: break-all; white-space: pre-wrap; color: var(--fg); background: var(--term-bg); padding: 8px; border-radius: 6px; border: 1px solid var(--border); max-height: 120px; overflow: auto; } @media (max-width: 800px) { .procs-top { grid-template-columns: 1fr; gap: 14px; padding-top: 16px; } .cmdline { max-height: 72px; } @@ -348,3 +349,31 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } .addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; } .header .badge.bad { color: var(--bad); border-color: var(--bad); } + +/* settings + themes */ +.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.theme-card { text-align: left; background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 10px; display: flex; flex-direction: column; gap: 6px; } +.theme-card:hover { border-color: var(--accent2); } .theme-card.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.theme-name { font-weight: 600; display: flex; align-items: center; gap: 8px; } +.tprev { border: 1px solid; border-radius: 6px; overflow: hidden; font-size: 9px; height: 96px; display: flex; flex-direction: column; } +.tprev-head { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; border-bottom: 1px solid; font-weight: 700; } +.tprev-dot { width: 6px; height: 6px; border-radius: 50%; } +.tprev-body { flex: 1; display: flex; } +.tprev-rail { width: 34px; border-right: 1px solid; display: flex; flex-direction: column; gap: 4px; padding: 6px 4px; } +.tprev-rail span { display: block; height: 7px; border-radius: 2px; background: transparent; } +.tprev-main { flex: 1; padding: 6px; display: flex; flex-direction: column; gap: 6px; } +.tprev-tile { border: 1px solid; border-radius: 4px; padding: 4px 6px; display: flex; flex-direction: column; gap: 2px; } .tprev-tile b { font-size: 12px; } +.tprev-bar { height: 4px; border-radius: 2px; overflow: hidden; } .tprev-bar div { height: 100%; } +.tprev-term { flex: 1; border-radius: 4px; padding: 4px 6px; font-family: var(--mono); } + +/* per-theme flourishes */ +[data-theme="cyberpunk"] .brand, [data-theme="cyberpunk"] .login h1 { text-shadow: 0 0 8px rgba(255, 43, 214, .7), 0 0 18px rgba(255, 43, 214, .35); } +[data-theme="cyberpunk"] .gicon, [data-theme="cyberpunk"] .box-title { text-shadow: 0 0 6px rgba(0, 229, 255, .6); } +[data-theme="cyberpunk"] .leaf.active, [data-theme="cyberpunk"] .group-head.active { box-shadow: inset 3px 0 0 var(--accent), 0 0 12px rgba(255, 43, 214, .25); } +[data-theme="retro"] body, [data-theme="retro"] .btn, [data-theme="retro"] input, [data-theme="retro"] select { font-family: var(--mono); } +[data-theme="retro"] .brand, [data-theme="retro"] .login h1 { text-shadow: 0 0 6px rgba(255, 176, 0, .6); letter-spacing: 1px; } +[data-theme="retro"] .app::after { content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 100; background: repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.12) 2px 3px); } +[data-theme="retro"] .tile, [data-theme="retro"] .card, [data-theme="retro"] .box { border-radius: 2px; } +[data-theme="light"] .term-overlay { background: rgba(255,255,255,.6); } +[data-theme="light"] .toast { box-shadow: 0 4px 16px rgba(0,0,0,.15); } +[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); } diff --git a/web/src/themes.ts b/web/src/themes.ts new file mode 100644 index 0000000..dbe3c8d --- /dev/null +++ b/web/src/themes.ts @@ -0,0 +1,83 @@ +// Global themes. Each one is a set of CSS variables applied to , a matching xterm +// palette, and a data-theme attribute the stylesheet can hang extra flourishes on. +import type { ITheme } from '@xterm/xterm' + +export interface Theme { + id: string + name: string + blurb: string + vars: Record + xterm: ITheme +} + +const base = { + '--ok': '#3fb950', '--bad': '#ff7b72', '--s1': '#3987e5', '--s2': '#c98500', '--track': 'rgba(57, 135, 229, .18)', + '--btn-fg': '#111', '--term-bg': '#0d1117', '--font': 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif', + '--mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', +} + +export const THEMES: Theme[] = [ + { + id: 'dark', name: 'Dark', blurb: 'The default. Graphite surfaces, amber brand, blue accents.', + vars: { ...base, '--bg': '#111418', '--bg2': '#171b21', '--bg3': '#1e242c', '--border': '#2a313b', '--fg': '#d0d7de', '--muted': '#7d8590', '--accent': '#e3a33b', '--accent2': '#58a6ff' }, + xterm: { + background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78', + black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', + brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc', + }, + }, + { + id: 'light', name: 'Light', blurb: 'Paper white with the same layout. For daylight and bright rooms.', + vars: { ...base, '--bg': '#f6f8fa', '--bg2': '#ffffff', '--bg3': '#eaeef2', '--border': '#d0d7de', '--fg': '#1f2328', '--muted': '#59636e', '--accent': '#b35c00', '--accent2': '#0969da', + '--ok': '#1a7f37', '--bad': '#cf222e', '--s1': '#2a78d6', '--s2': '#9a6700', '--track': 'rgba(42, 120, 214, .16)', '--btn-fg': '#ffffff', '--term-bg': '#ffffff' }, + xterm: { + background: '#ffffff', foreground: '#1f2328', cursor: '#1f2328', selectionBackground: '#b6d6ff', + black: '#24292f', red: '#cf222e', green: '#116329', yellow: '#4d2d00', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#6e7781', + brightBlack: '#57606a', brightRed: '#a40e26', brightGreen: '#1a7f37', brightYellow: '#633c01', brightBlue: '#218bff', brightMagenta: '#a475f9', brightCyan: '#3192aa', brightWhite: '#8c959f', + }, + }, + { + id: 'cyberpunk', name: 'Cyberpunk', blurb: 'Deep violet, magenta brand, cyan accents, a little neon glow.', + vars: { ...base, '--bg': '#0b0014', '--bg2': '#140026', '--bg3': '#1f0a3a', '--border': '#3b1f6b', '--fg': '#e8e0ff', '--muted': '#9b8bd0', '--accent': '#ff2bd6', '--accent2': '#00e5ff', + '--ok': '#39ff14', '--bad': '#ff3860', '--s1': '#00e5ff', '--s2': '#ffe600', '--track': 'rgba(0, 229, 255, .16)', '--btn-fg': '#0b0014', '--term-bg': '#07000f' }, + xterm: { + background: '#07000f', foreground: '#e8e0ff', cursor: '#ff2bd6', selectionBackground: '#3b1f6b', + black: '#0b0014', red: '#ff3860', green: '#39ff14', yellow: '#ffe600', blue: '#00e5ff', magenta: '#ff2bd6', cyan: '#5cf6ff', white: '#c8bff0', + brightBlack: '#6a55a3', brightRed: '#ff6b8a', brightGreen: '#7dff5c', brightYellow: '#fff27a', brightBlue: '#66eeff', brightMagenta: '#ff7de8', brightCyan: '#a5fbff', brightWhite: '#ffffff', + }, + }, + { + id: 'retro', name: 'Retro', blurb: 'Amber phosphor terminal. Monospace everywhere, faint scanlines.', + vars: { ...base, '--bg': '#0e0b05', '--bg2': '#171106', '--bg3': '#221909', '--border': '#3a2c10', '--fg': '#ffb000', '--muted': '#a8781c', '--accent': '#ffcc33', '--accent2': '#ffb000', + '--ok': '#ffd166', '--bad': '#ff6b35', '--s1': '#ffb000', '--s2': '#ff8c00', '--track': 'rgba(255, 176, 0, .16)', '--btn-fg': '#0e0b05', '--term-bg': '#0a0803', + '--font': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' }, + xterm: { + background: '#0a0803', foreground: '#ffb000', cursor: '#ffcc33', selectionBackground: '#3a2c10', + black: '#0a0803', red: '#ff6b35', green: '#ffd166', yellow: '#ffcc33', blue: '#e0a020', magenta: '#ff9f43', cyan: '#f5c76b', white: '#ffb000', + brightBlack: '#6b4e14', brightRed: '#ff8c5a', brightGreen: '#ffe08a', brightYellow: '#ffe066', brightBlue: '#f0b040', brightMagenta: '#ffb870', brightCyan: '#ffdd99', brightWhite: '#fff3cc', + }, + }, +] + +export const DEFAULT_THEME = 'dark' +const KEY = 'hamlab.theme' + +export function themeById(id: string | null | undefined): Theme { + return THEMES.find((t) => t.id === id) ?? THEMES[0] +} + +export function currentThemeId(): string { + return localStorage.getItem(KEY) || DEFAULT_THEME +} + +// applyTheme sets the variables on , remembers the choice, and tells open +// terminals to repaint. Cheap enough to call on every load before first render. +export function applyTheme(id: string) { + const t = themeById(id) + const root = document.documentElement + for (const [k, v] of Object.entries(t.vars)) root.style.setProperty(k, v) + root.dataset.theme = t.id + localStorage.setItem(KEY, t.id) + document.querySelector('meta[name=theme-color]')?.setAttribute('content', t.vars['--bg2']) + window.dispatchEvent(new CustomEvent('hamlab:theme', { detail: t })) +}