Add light mode theme setting and remove offline banner
Release rolling / release (push) Has been cancelled

Dark remains default; users can pick light or match system under Settings → Appearance. Drop the full-height connection-lost banner that stretched across the window.
This commit is contained in:
Raven Scott
2026-07-16 17:03:08 -04:00
parent f72a1d0a41
commit 3a3df4ec10
6 changed files with 1254 additions and 54 deletions
+118 -22
View File
@@ -98,6 +98,12 @@ export const DEFAULT_SETTINGS = {
*/
reconnectMaxAttempts: 8,
// Track G — appearance / motion
/**
* UI color theme. Dark is default.
* `system` follows prefers-color-scheme.
* @type {'dark' | 'light' | 'system'}
*/
theme: 'dark',
reduceMotion: false,
accent: 'teal', // teal | cyan | violet
showRefreshStamp: true,
@@ -131,6 +137,99 @@ export function normalizeHiddenContainerLabels(raw) {
return out
}
/**
* Normalize theme preference from settings.
* @param {unknown} raw
* @returns {'dark' | 'light' | 'system'}
*/
export function normalizeThemePreference(raw) {
const v = String(raw || '').toLowerCase()
if (v === 'light' || v === 'system') return v
return 'dark'
}
/**
* Resolve stored preference to an applied theme (dark | light).
* @param {'dark' | 'light' | 'system' | string | undefined} preference
* @returns {'dark' | 'light'}
*/
export function resolveTheme(preference) {
const pref = normalizeThemePreference(preference)
if (pref === 'light') return 'light'
if (pref === 'system') {
try {
if (typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches) {
return 'light'
}
} catch {
// ignore
}
return 'dark'
}
return 'dark'
}
/** @type {MediaQueryList | null} */
let systemThemeMql = null
/** @type {((this: MediaQueryList, ev: MediaQueryListEvent) => void) | null} */
let systemThemeHandler = null
/**
* Apply resolved color theme to document (html + body data-theme).
* @param {'dark' | 'light' | 'system' | string | undefined} preference
* @returns {'dark' | 'light'}
*/
export function applyColorTheme(preference) {
const pref = normalizeThemePreference(preference)
const resolved = resolveTheme(pref)
try {
const root = document.documentElement
root.dataset.theme = resolved
root.dataset.themePref = pref
if (document.body) {
document.body.dataset.theme = resolved
document.body.dataset.themePref = pref
}
// Browser chrome / PWA status bar
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) {
meta.setAttribute('content', resolved === 'light' ? '#f3f5f8' : '#0a0c10')
}
const tile = document.querySelector('meta[name="msapplication-TileColor"]')
if (tile) {
tile.setAttribute('content', resolved === 'light' ? '#f3f5f8' : '#0f1117')
}
} catch {
// ignore
}
// Follow OS when preference is system
try {
if (systemThemeMql && systemThemeHandler) {
systemThemeMql.removeEventListener?.('change', systemThemeHandler)
// Safari < 14
systemThemeMql.removeListener?.(systemThemeHandler)
}
systemThemeMql = null
systemThemeHandler = null
if (pref === 'system' && typeof matchMedia === 'function') {
systemThemeMql = matchMedia('(prefers-color-scheme: light)')
systemThemeHandler = () => {
applyColorTheme('system')
}
if (systemThemeMql.addEventListener) {
systemThemeMql.addEventListener('change', systemThemeHandler)
} else if (systemThemeMql.addListener) {
systemThemeMql.addListener(systemThemeHandler)
}
}
} catch {
// ignore
}
return resolved
}
export function loadSettings() {
try {
const raw = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')
@@ -145,6 +244,7 @@ export function loadSettings() {
merged.hiddenContainerLabels = normalizeHiddenContainerLabels(
raw.hiddenContainerLabels ?? DEFAULT_SETTINGS.hiddenContainerLabels
)
merged.theme = normalizeThemePreference(raw.theme ?? DEFAULT_SETTINGS.theme)
// Reconnect: 0 = unlimited; 18 finite (legacy missing key → default 8)
if (raw.reconnectMaxAttempts === undefined || raw.reconnectMaxAttempts === null) {
merged.reconnectMaxAttempts = DEFAULT_SETTINGS.reconnectMaxAttempts
@@ -175,6 +275,7 @@ export function applySettings(s = loadSettings()) {
document.body.dataset.accent = s.accent || 'teal'
document.body.dataset.reduceMotion = s.reduceMotion ? '1' : '0'
document.body.dataset.refreshStamp = s.showRefreshStamp === false ? '0' : '1'
applyColorTheme(s.theme || 'dark')
startListAutoRefresh(s.refreshSeconds)
// Push reconnect policy into ConnectionManager (0 = unlimited, 18 finite)
@@ -263,14 +364,6 @@ export function startListAutoRefresh(seconds) {
}, sec * 1000)
}
export function setOfflineBanner(visible, text) {
const el = document.getElementById('offline-banner')
const t = document.getElementById('offline-banner-text')
if (!el) return
el.classList.toggle('visible', Boolean(visible))
if (t && text) t.textContent = text
}
export function updateActivePeerChip(conn) {
const label = document.getElementById('active-peer-label')
const dot = document.getElementById('active-peer-dot')
@@ -1781,9 +1874,11 @@ export function readSettingsForm() {
: 'comfortable'
const accentRaw = document.getElementById('settings-accent')?.value || 'teal'
const accent = ['teal', 'cyan', 'violet'].includes(accentRaw) ? accentRaw : 'teal'
const theme = normalizeThemePreference(document.getElementById('settings-theme')?.value)
return {
density,
accent,
theme,
reduceMotion: document.getElementById('settings-reduce-motion')?.value === '1',
showRefreshStamp: document.getElementById('settings-refresh-stamp')?.value !== '0',
sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed',
@@ -2063,6 +2158,7 @@ async function runRestorePackage(pkg, opts = {}) {
export function loadSettingsView(forceTab) {
const s = loadSettings()
setSelectValue('settings-density', s.density || 'comfortable')
setSelectValue('settings-theme', normalizeThemePreference(s.theme))
setSelectValue('settings-accent', s.accent || 'teal')
setSelectValue('settings-reduce-motion', s.reduceMotion ? '1' : '0')
setSelectValue('settings-refresh-stamp', s.showRefreshStamp === false ? '0' : '1')
@@ -2450,10 +2546,6 @@ export function initOpsApp({ navigateToView, sendCommand }) {
})
.catch((err) => console.warn('[ops] track-g-ux failed to load', err))
document.getElementById('offline-banner-dismiss')?.addEventListener('click', () => {
setOfflineBanner(false)
})
document.getElementById('smart-net-resuggest')?.addEventListener('click', async () => {
try {
const ipam = await suggestNetwork()
@@ -2526,6 +2618,17 @@ export function initOpsApp({ navigateToView, sendCommand }) {
showSettingsTab(btn.getAttribute('data-settings-tab'))
})
// Live theme preview (persists on Save all)
document.getElementById('settings-theme')?.addEventListener('change', (e) => {
applyColorTheme(e.target?.value || 'dark')
})
document.getElementById('settings-accent')?.addEventListener('change', (e) => {
const accent = String(e.target?.value || 'teal')
if (['teal', 'cyan', 'violet'].includes(accent)) {
document.body.dataset.accent = accent
}
})
document.getElementById('settings-save-btn')?.addEventListener('click', (e) => {
const btn = e.currentTarget
if (btn?.classList.contains('is-saving') || btn?.classList.contains('is-saved')) return
@@ -2819,25 +2922,15 @@ export function initOpsApp({ navigateToView, sendCommand }) {
warmSnapshot()
refreshSwarmNavState().catch(() => {})
updateActivePeerChip(conn)
setOfflineBanner(false)
})
manager.on('disconnect', () => {
clearSnapshotCache()
updateActivePeerChip(manager.active)
if (!manager.active?.connected) {
setOfflineBanner(true, 'Connection lost — reconnecting…')
setSwarmNavEnabled(false, SWARM_DISABLED_NAV_TITLE)
}
})
manager.on('reconnecting', ({ attempt, delayMs }) => {
const secs = Math.max(1, Math.round((delayMs || 5000) / 1000))
setOfflineBanner(
true,
`Connection lost — retrying every ${secs}s (attempt ${attempt})…`
)
})
manager.on('reconnected', () => {
setOfflineBanner(false)
warmSnapshot()
refreshSwarmNavState().catch(() => {})
})
@@ -2889,6 +2982,9 @@ export function initOpsApp({ navigateToView, sendCommand }) {
loadSettings,
saveSettings,
applySettings,
applyColorTheme,
resolveTheme,
normalizeThemePreference,
shouldShowFirstConnectTip,
showSettingsTab,
DEFAULT_SETTINGS,
-17
View File
@@ -18,23 +18,6 @@
padding: 0.75rem 0.85rem 0.2rem;
}
#offline-banner {
display: none;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: linear-gradient(90deg, #7c2d12, #9a3412);
color: #fff7ed;
font-size: 0.9rem;
z-index: 2000;
}
#offline-banner.visible {
display: flex;
}
#offline-banner .btn {
margin-left: auto;
}
.pd-badge {
display: inline-flex;
align-items: center;
+1048
View File
File diff suppressed because it is too large Load Diff