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:
Samuel James 2026-06-22 16:39:50 -04:00 committed by GitHub
parent d8223b01cc
commit 08139ff831
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 154 additions and 20 deletions

View file

@ -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
**Status:** not built; planned as a paid-tier feature.

View file

@ -8,7 +8,13 @@
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-card: #141518;
--color-sidebar: #0A0B0D;
@ -20,6 +26,40 @@
--color-text-primary: #E8E6E0;
--color-text-secondary: #7A7D85;
--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 {
@ -32,13 +72,14 @@ html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #0D0E10;
color: #E8E6E0;
background-color: var(--color-page);
color: var(--color-text-primary);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.2s ease, color 0.2s ease;
}
#root {
@ -86,7 +127,7 @@ select {
border: 1px solid rgba(200, 164, 52, 0.18) !important;
border-radius: 8px !important;
padding: 9px 12px !important;
background-color: rgba(255, 255, 255, 0.04) !important;
background-color: var(--select-bg) !important;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@ -102,6 +143,6 @@ select:focus {
}
select option {
background-color: #141518;
color: #E8E6E0;
background-color: var(--select-option-bg);
color: var(--color-text-primary);
}

48
src/lib/theme.ts Normal file
View 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')
}

View file

@ -5,6 +5,10 @@ import './index.css'
import App from './App.tsx'
import { AuthProvider } from './lib/AuthContext'
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(
<StrictMode>

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api'
import { useAuth } from '../lib/AuthContext'
import { loadAppearance, saveAppearance, applyAppearance, type AppearancePrefs } from '../lib/theme'
import {
User,
Palette,
@ -289,12 +290,23 @@ function ProfileSection() {
}
function AppearanceSection() {
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
const [prefs, setPrefs] = useState<AppearancePrefs>(loadAppearance)
const [accent, setAccent] = useState('Gold')
const [fontSize, setFontSize] = useState(13)
const [radius, setRadius] = useState(12)
const [sidebarExpanded, setSidebarExpanded] = useState(true)
const [animations, setAnimations] = useState(true)
// Persist + apply on every change so the UI updates live.
function update(patch: Partial<AppearancePrefs>) {
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 (
<div style={cardBase}>
@ -306,7 +318,7 @@ function AppearanceSection() {
{(['dark', 'light'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
onClick={() => update({ theme: t })}
className="cursor-pointer border-none capitalize"
style={{
fontSize: '11px',
@ -356,7 +368,7 @@ function AppearanceSection() {
min={12}
max={16}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
onChange={(e) => update({ fontSize: Number(e.target.value) })}
className="w-full"
style={{ accentColor: '#C8A434' }}
/>
@ -372,20 +384,15 @@ function AppearanceSection() {
min={4}
max={16}
value={radius}
onChange={(e) => setRadius(Number(e.target.value))}
onChange={(e) => update({ radius: Number(e.target.value) })}
className="w-full"
style={{ accentColor: '#C8A434' }}
/>
</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">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
<Toggle on={animations} onClick={() => update({ animations: !animations })} />
</div>
</div>
)