Make Appearance light mode work (gray theme) + roadmap GNOME/KDE RDP (#50)
Appearance tab was local-state-only — light mode did nothing. Wire it up: - index.css: theme color tokens are now CSS variables on :root (dark default) with a [data-theme="light"] override using a soft GRAY page background (#E4E6EB), not white. The @theme tokens + html/body + select reference the vars, so the app shell and any component using bg-page/text-text-primary/etc. themes automatically. - New src/lib/theme.ts: localStorage-backed appearance prefs (theme/fontSize/ radius/animations) + applyAppearance() toggling data-theme on <html>, mirroring the terminalPrefs pattern. - main.tsx applies saved theme before first render (no flash). - Settings AppearanceSection persists + applies on change; theme/fontSize/radius/ animations are live. Dropped the non-functional "Sidebar Expanded by default" toggle. (accent color is still cosmetic-only — full token migration of hardcoded-hex pages is a separate task, noted in ROADMAP.) Also adds the Remote Desktop GNOME & KDE support work to ROADMAP as an add-on (XFCE confirmed working; GNOME needs a FreeRDP-3 guacd image, KDE via xrdp + startplasma-x11). Full detail in docs/rdp-debug-handoff.md. Co-authored-by: Samuel James <ssamjame@amazon.com> Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
parent
d8223b01cc
commit
08139ff831
5 changed files with 154 additions and 20 deletions
34
ROADMAP.md
34
ROADMAP.md
|
|
@ -136,6 +136,40 @@ A complementary **agent** model is planned, split across tiers:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Remote Desktop — GNOME & KDE support — ADD-ON
|
||||||
|
|
||||||
|
**Status:** not built; **XFCE is confirmed working today**, the others are not.
|
||||||
|
Full investigation + research lives in `docs/rdp-debug-handoff.md`.
|
||||||
|
|
||||||
|
Remote Desktop (Guacamole/`guacd` → RDP) works end-to-end **only with XFCE** on
|
||||||
|
the target VM, via xrdp's X11 backend. GNOME and KDE do **not** work yet:
|
||||||
|
|
||||||
|
- **GNOME (Wayland-only on modern distros)** is blocked two ways: it ships no
|
||||||
|
Xorg session for xrdp to launch, *and* its native `gnome-remote-desktop`
|
||||||
|
mandates NLA that guacd's bundled **FreeRDP 2.x cannot complete** ("wrong
|
||||||
|
security type"). Verified on Fedora 44 / GNOME 50.
|
||||||
|
- **KDE Plasma 6** is expected to work like XFCE via xrdp + `startplasma-x11`
|
||||||
|
(X11 session shipped through ~early 2027), but is **not yet installed/tested**.
|
||||||
|
|
||||||
|
Planned scope (add-on):
|
||||||
|
1. **Custom `guacd` image built against FreeRDP 3** (Apache's official 1.5.5 and
|
||||||
|
1.6.0 images both still ship FreeRDP 2.11.x). This is the real unlock for
|
||||||
|
GNOME's native Wayland RDP and benefits any modern GNOME / Ubuntu-24.04+
|
||||||
|
target — not just this VM. ~30-min from-source build to maintain in
|
||||||
|
`docker-compose.yml`.
|
||||||
|
2. **GNOME headless "system" RDP** (GDM handover, GNOME 46+) — the intended
|
||||||
|
modern path; only viable once (1) lands because it still uses NLA.
|
||||||
|
3. **KDE Plasma** via xrdp + `startplasma-x11` (quick win, no guacd change;
|
||||||
|
likely needs a KWin compositing/software-GL tweak on virtual GPUs).
|
||||||
|
4. **Per-host desktop/session selection** instead of a single global
|
||||||
|
`/etc/xrdp/startwm.sh`, so one VM can offer XFCE / KDE / GNOME.
|
||||||
|
|
||||||
|
See `docs/rdp-debug-handoff.md` for the suggested order of work and primary-source
|
||||||
|
references (SUSE headless-GNOME series, jamesnorth GRD setup, RHEL 10 docs, KDE
|
||||||
|
discuss threads).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Per-integration node tabs — PAID ADD-ON
|
## Per-integration node tabs — PAID ADD-ON
|
||||||
|
|
||||||
**Status:** not built; planned as a paid-tier feature.
|
**Status:** not built; planned as a paid-tier feature.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
/* Theme color tokens live as CSS variables so light/dark can swap at runtime
|
||||||
|
by toggling data-theme on <html>. Dark is the default (:root). Light mode
|
||||||
|
uses a soft GRAY page background (not pure white) per design preference.
|
||||||
|
Components that use the Tailwind token classes (bg-page, text-text-primary,
|
||||||
|
border-border, etc.) or var(--color-*) pick this up automatically; some
|
||||||
|
pages still hardcode hex and won't fully theme yet (tracked in ROADMAP). */
|
||||||
|
:root {
|
||||||
--color-page: #0D0E10;
|
--color-page: #0D0E10;
|
||||||
--color-card: #141518;
|
--color-card: #141518;
|
||||||
--color-sidebar: #0A0B0D;
|
--color-sidebar: #0A0B0D;
|
||||||
|
|
@ -20,6 +26,40 @@
|
||||||
--color-text-primary: #E8E6E0;
|
--color-text-primary: #E8E6E0;
|
||||||
--color-text-secondary: #7A7D85;
|
--color-text-secondary: #7A7D85;
|
||||||
--color-teal: #1ABC9C;
|
--color-teal: #1ABC9C;
|
||||||
|
--select-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
--select-option-bg: #141518;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--color-page: #E4E6EB; /* soft gray, not white */
|
||||||
|
--color-card: #F4F5F7; /* slightly lighter gray cards */
|
||||||
|
--color-sidebar: #DADCE2; /* darker gray sidebar for separation */
|
||||||
|
--color-border: #C4C7CF;
|
||||||
|
--color-gold: #B08D2A; /* slightly darker gold for contrast on light */
|
||||||
|
--color-success: #1E9E57;
|
||||||
|
--color-warning: #C96A18;
|
||||||
|
--color-danger: #C0392B;
|
||||||
|
--color-text-primary: #1C1E22;
|
||||||
|
--color-text-secondary: #5A5D66;
|
||||||
|
--color-teal: #138D75;
|
||||||
|
--select-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--select-option-bg: #F4F5F7;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-page: var(--color-page);
|
||||||
|
--color-card: var(--color-card);
|
||||||
|
--color-sidebar: var(--color-sidebar);
|
||||||
|
--color-border: var(--color-border);
|
||||||
|
--color-gold: var(--color-gold);
|
||||||
|
--color-success: var(--color-success);
|
||||||
|
--color-warning: var(--color-warning);
|
||||||
|
--color-danger: var(--color-danger);
|
||||||
|
--color-text-primary: var(--color-text-primary);
|
||||||
|
--color-text-secondary: var(--color-text-secondary);
|
||||||
|
--color-teal: var(--color-teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|
@ -32,13 +72,14 @@ html, body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #0D0E10;
|
background-color: var(--color-page);
|
||||||
color: #E8E6E0;
|
color: var(--color-text-primary);
|
||||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|
@ -86,7 +127,7 @@ select {
|
||||||
border: 1px solid rgba(200, 164, 52, 0.18) !important;
|
border: 1px solid rgba(200, 164, 52, 0.18) !important;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
padding: 9px 12px !important;
|
padding: 9px 12px !important;
|
||||||
background-color: rgba(255, 255, 255, 0.04) !important;
|
background-color: var(--select-bg) !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +143,6 @@ select:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
select option {
|
select option {
|
||||||
background-color: #141518;
|
background-color: var(--select-option-bg);
|
||||||
color: #E8E6E0;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
src/lib/theme.ts
Normal file
48
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// App appearance prefs (theme + a few visual knobs), persisted to localStorage
|
||||||
|
// and applied by toggling attributes / CSS variables on <html>. Kept out of a
|
||||||
|
// component module so it can be imported anywhere (and called once at boot in
|
||||||
|
// main.tsx before React renders, to avoid a dark→light flash).
|
||||||
|
|
||||||
|
export type ThemeMode = 'dark' | 'light'
|
||||||
|
|
||||||
|
export interface AppearancePrefs {
|
||||||
|
theme: ThemeMode
|
||||||
|
fontSize: number // base font-size in px
|
||||||
|
radius: number // card border-radius in px
|
||||||
|
animations: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFS_KEY = 'archnest-appearance-prefs'
|
||||||
|
|
||||||
|
export function defaultAppearance(): AppearancePrefs {
|
||||||
|
return { theme: 'dark', fontSize: 14, radius: 12, animations: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAppearance(): AppearancePrefs {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREFS_KEY)
|
||||||
|
if (raw) return { ...defaultAppearance(), ...JSON.parse(raw) }
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed local storage */
|
||||||
|
}
|
||||||
|
return defaultAppearance()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAppearance(prefs: AppearancePrefs) {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply prefs to the document. Light mode flips data-theme="light" (which the
|
||||||
|
// CSS variable overrides in index.css key off of); dark removes the attribute.
|
||||||
|
// ponytail: only theme is wired to real CSS right now; fontSize/radius/animations
|
||||||
|
// are persisted and exposed as CSS vars for components to opt into later — the
|
||||||
|
// app still mostly hardcodes hex, so a full token migration is a separate task
|
||||||
|
// (tracked in ROADMAP "Appearance section").
|
||||||
|
export function applyAppearance(prefs: AppearancePrefs) {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (prefs.theme === 'light') root.setAttribute('data-theme', 'light')
|
||||||
|
else root.removeAttribute('data-theme')
|
||||||
|
root.style.setProperty('--app-font-size', `${prefs.fontSize}px`)
|
||||||
|
root.style.setProperty('--card-radius', `${prefs.radius}px`)
|
||||||
|
root.style.setProperty('--app-animations', prefs.animations ? '1' : '0')
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@ import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { AuthProvider } from './lib/AuthContext'
|
import { AuthProvider } from './lib/AuthContext'
|
||||||
import { TerminalSessionProvider } from './lib/TerminalSessionContext'
|
import { TerminalSessionProvider } from './lib/TerminalSessionContext'
|
||||||
|
import { applyAppearance, loadAppearance } from './lib/theme'
|
||||||
|
|
||||||
|
// Apply saved theme before first render to avoid a dark→light flash.
|
||||||
|
applyAppearance(loadAppearance())
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api'
|
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api'
|
||||||
import { useAuth } from '../lib/AuthContext'
|
import { useAuth } from '../lib/AuthContext'
|
||||||
|
import { loadAppearance, saveAppearance, applyAppearance, type AppearancePrefs } from '../lib/theme'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
@ -289,12 +290,23 @@ function ProfileSection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
const [prefs, setPrefs] = useState<AppearancePrefs>(loadAppearance)
|
||||||
const [accent, setAccent] = useState('Gold')
|
const [accent, setAccent] = useState('Gold')
|
||||||
const [fontSize, setFontSize] = useState(13)
|
|
||||||
const [radius, setRadius] = useState(12)
|
// Persist + apply on every change so the UI updates live.
|
||||||
const [sidebarExpanded, setSidebarExpanded] = useState(true)
|
function update(patch: Partial<AppearancePrefs>) {
|
||||||
const [animations, setAnimations] = useState(true)
|
setPrefs((prev) => {
|
||||||
|
const next = { ...prev, ...patch }
|
||||||
|
saveAppearance(next)
|
||||||
|
applyAppearance(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = prefs.theme
|
||||||
|
const fontSize = prefs.fontSize
|
||||||
|
const radius = prefs.radius
|
||||||
|
const animations = prefs.animations
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardBase}>
|
<div style={cardBase}>
|
||||||
|
|
@ -306,7 +318,7 @@ function AppearanceSection() {
|
||||||
{(['dark', 'light'] as const).map((t) => (
|
{(['dark', 'light'] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTheme(t)}
|
onClick={() => update({ theme: t })}
|
||||||
className="cursor-pointer border-none capitalize"
|
className="cursor-pointer border-none capitalize"
|
||||||
style={{
|
style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
|
|
@ -356,7 +368,7 @@ function AppearanceSection() {
|
||||||
min={12}
|
min={12}
|
||||||
max={16}
|
max={16}
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
onChange={(e) => update({ fontSize: Number(e.target.value) })}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ accentColor: '#C8A434' }}
|
style={{ accentColor: '#C8A434' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -372,20 +384,15 @@ function AppearanceSection() {
|
||||||
min={4}
|
min={4}
|
||||||
max={16}
|
max={16}
|
||||||
value={radius}
|
value={radius}
|
||||||
onChange={(e) => setRadius(Number(e.target.value))}
|
onChange={(e) => update({ radius: Number(e.target.value) })}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ accentColor: '#C8A434' }}
|
style={{ accentColor: '#C8A434' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
||||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sidebar Expanded by Default</span>
|
|
||||||
<Toggle on={sidebarExpanded} onClick={() => setSidebarExpanded((v) => !v)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
|
||||||
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
|
<Toggle on={animations} onClick={() => update({ animations: !animations })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue