Expand Appearance settings with accents, scale, radius, and chrome
Release rolling / release (push) Successful in 7m33s

Add twelve accent colors with swatch picker, UI scale, corner radius,
and titlebar/sidebar chrome styles. Preferences live-apply and persist
in the settings cache for both dark and light themes.
This commit is contained in:
Raven Scott
2026-07-17 23:47:07 -04:00
parent a78ef40966
commit a6fea51eaf
6 changed files with 485 additions and 16 deletions
+144 -1
View File
@@ -4604,17 +4604,160 @@ body[data-density='spacious'] .view {
padding-top: 1.5rem !important;
}
/* Accent variants (Track G) */
/* Accent variants (Settings → Appearance). Default = teal (root vars). */
body[data-accent='teal'] {
--accent-primary: #34d399;
--accent-secondary: #2dd4bf;
--accent-glow: rgba(52, 211, 153, 0.35);
}
body[data-accent='cyan'] {
--accent-primary: #22d3ee;
--accent-secondary: #67e8f9;
--accent-glow: rgba(34, 211, 238, 0.35);
}
body[data-accent='emerald'] {
--accent-primary: #10b981;
--accent-secondary: #34d399;
--accent-glow: rgba(16, 185, 129, 0.35);
}
body[data-accent='blue'] {
--accent-primary: #60a5fa;
--accent-secondary: #93c5fd;
--accent-glow: rgba(96, 165, 250, 0.35);
}
body[data-accent='indigo'] {
--accent-primary: #818cf8;
--accent-secondary: #a5b4fc;
--accent-glow: rgba(129, 140, 248, 0.35);
}
body[data-accent='violet'] {
--accent-primary: #a78bfa;
--accent-secondary: #c4b5fd;
--accent-glow: rgba(167, 139, 250, 0.35);
}
body[data-accent='fuchsia'] {
--accent-primary: #e879f9;
--accent-secondary: #f0abfc;
--accent-glow: rgba(232, 121, 249, 0.35);
}
body[data-accent='rose'] {
--accent-primary: #fb7185;
--accent-secondary: #fda4af;
--accent-glow: rgba(251, 113, 133, 0.35);
}
body[data-accent='amber'] {
--accent-primary: #fbbf24;
--accent-secondary: #fcd34d;
--accent-glow: rgba(251, 191, 36, 0.35);
}
body[data-accent='orange'] {
--accent-primary: #fb923c;
--accent-secondary: #fdba74;
--accent-glow: rgba(251, 146, 60, 0.35);
}
body[data-accent='sky'] {
--accent-primary: #38bdf8;
--accent-secondary: #7dd3fc;
--accent-glow: rgba(56, 189, 248, 0.35);
}
body[data-accent='lime'] {
--accent-primary: #a3e635;
--accent-secondary: #bef264;
--accent-glow: rgba(163, 230, 53, 0.35);
}
/* UI scale (base type) */
body[data-ui-scale='sm'] {
font-size: 13px;
}
body[data-ui-scale='sm'] .nav-link {
font-size: 12.5px;
}
body[data-ui-scale='lg'] {
font-size: 16px;
}
body[data-ui-scale='lg'] .nav-link {
font-size: 14.5px;
}
body[data-ui-scale='lg'] .form-label,
body[data-ui-scale='lg'] .form-text {
font-size: 0.95em;
}
/* Corner radius tokens */
body[data-radius='sharp'] {
--border-radius: 6px;
--border-radius-sm: 4px;
--border-radius-lg: 8px;
}
body[data-radius='round'] {
--border-radius: 18px;
--border-radius-sm: 12px;
--border-radius-lg: 24px;
}
/* Chrome surface: titlebar / sidebar material */
body[data-chrome='solid'] #titlebar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--bg-secondary, #0f131a);
}
body[data-chrome='solid'] #sidebar {
background: var(--bg-secondary, #0f131a);
}
body[data-chrome='flat'] #titlebar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--bg-primary, #0a0c10);
border-bottom-color: transparent;
box-shadow: none;
}
body[data-chrome='flat'] #sidebar {
background: var(--bg-primary, #0a0c10);
border-right-color: transparent;
box-shadow: none;
}
/* Accent swatches (Settings → Appearance) */
.pd-accent-swatches {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.pd-accent-swatch {
width: 30px;
height: 30px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(135deg, var(--swatch, #34d399), var(--swatch-2, var(--swatch, #2dd4bf)));
cursor: pointer;
padding: 0;
box-shadow: 0 0 0 0 transparent;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
-webkit-app-region: no-drag;
}
.pd-accent-swatch:hover {
transform: scale(1.08);
border-color: rgba(255, 255, 255, 0.35);
}
.pd-accent-swatch:focus-visible {
outline: 2px solid var(--accent-secondary, #2dd4bf);
outline-offset: 2px;
}
.pd-accent-swatch.is-active {
border-color: #fff;
box-shadow:
0 0 0 2px var(--bg-primary, #0a0c10),
0 0 0 4px var(--swatch, var(--accent-primary, #34d399));
transform: scale(1.06);
}
.pd-accent-select {
max-width: 16rem;
}
body[data-reduce-motion='1'] .pd-accent-swatch {
transition: none;
}
/* Sticky table headers in scrollable resource tables */
.table-responsive,
+180 -5
View File
@@ -77,6 +77,75 @@ import {
const SIDEBAR_KEY = 'peardock.sidebar.collapsed'
const FIRST_CONNECT_KEY = 'peardock.firstConnect.dismissed'
/**
* Accent color presets (dark UI). Light theme overrides live in theme-light.css.
* Keys are stored in settings.accent and applied as body[data-accent].
*/
export const ACCENT_PRESETS = Object.freeze({
teal: { label: 'Teal', primary: '#34d399', secondary: '#2dd4bf' },
cyan: { label: 'Cyan', primary: '#22d3ee', secondary: '#67e8f9' },
emerald: { label: 'Emerald', primary: '#10b981', secondary: '#34d399' },
blue: { label: 'Blue', primary: '#60a5fa', secondary: '#93c5fd' },
indigo: { label: 'Indigo', primary: '#818cf8', secondary: '#a5b4fc' },
violet: { label: 'Violet', primary: '#a78bfa', secondary: '#c4b5fd' },
fuchsia: { label: 'Fuchsia', primary: '#e879f9', secondary: '#f0abfc' },
rose: { label: 'Rose', primary: '#fb7185', secondary: '#fda4af' },
amber: { label: 'Amber', primary: '#fbbf24', secondary: '#fcd34d' },
orange: { label: 'Orange', primary: '#fb923c', secondary: '#fdba74' },
sky: { label: 'Sky', primary: '#38bdf8', secondary: '#7dd3fc' },
lime: { label: 'Lime', primary: '#a3e635', secondary: '#bef264' },
})
export const ACCENT_IDS = Object.freeze(Object.keys(ACCENT_PRESETS))
const UI_SCALE_IDS = new Set(['sm', 'md', 'lg'])
const RADIUS_IDS = new Set(['sharp', 'default', 'round'])
const CHROME_IDS = new Set(['glass', 'solid', 'flat'])
/**
* @param {unknown} raw
* @returns {string}
*/
export function normalizeAccent(raw) {
const v = String(raw || '')
.toLowerCase()
.trim()
return ACCENT_PRESETS[v] ? v : 'teal'
}
/**
* @param {unknown} raw
* @returns {'sm' | 'md' | 'lg'}
*/
export function normalizeUiScale(raw) {
const v = String(raw || '')
.toLowerCase()
.trim()
return UI_SCALE_IDS.has(v) ? /** @type {'sm' | 'md' | 'lg'} */ (v) : 'md'
}
/**
* @param {unknown} raw
* @returns {'sharp' | 'default' | 'round'}
*/
export function normalizeBorderRadius(raw) {
const v = String(raw || '')
.toLowerCase()
.trim()
return RADIUS_IDS.has(v) ? /** @type {'sharp' | 'default' | 'round'} */ (v) : 'default'
}
/**
* @param {unknown} raw
* @returns {'glass' | 'solid' | 'flat'}
*/
export function normalizeChromeStyle(raw) {
const v = String(raw || '')
.toLowerCase()
.trim()
return CHROME_IDS.has(v) ? /** @type {'glass' | 'solid' | 'flat'} */ (v) : 'glass'
}
/** Defaults for client-side preferences (~/.config/peardock/cache/settings.json). */
export const DEFAULT_SETTINGS = {
density: 'comfortable', // comfortable | compact | spacious
@@ -147,7 +216,26 @@ export const DEFAULT_SETTINGS = {
*/
theme: 'dark',
reduceMotion: false,
accent: 'teal', // teal | cyan | violet
/**
* Brand accent for highlights / focus / active nav.
* @type {keyof typeof ACCENT_PRESETS}
*/
accent: 'teal',
/**
* Global UI type scale.
* @type {'sm' | 'md' | 'lg'}
*/
uiScale: 'md',
/**
* Corner radius of cards, buttons, inputs.
* @type {'sharp' | 'default' | 'round'}
*/
borderRadius: 'default',
/**
* Titlebar / sidebar surface treatment.
* @type {'glass' | 'solid' | 'flat'}
*/
chromeStyle: 'glass',
showRefreshStamp: true,
/**
* Global hide rules for containers that carry matching labels.
@@ -440,6 +528,12 @@ export function loadSettings() {
}
}
merged.theme = normalizeThemePreference(raw.theme ?? DEFAULT_SETTINGS.theme)
merged.accent = normalizeAccent(raw.accent ?? DEFAULT_SETTINGS.accent)
merged.uiScale = normalizeUiScale(raw.uiScale ?? DEFAULT_SETTINGS.uiScale)
merged.borderRadius = normalizeBorderRadius(
raw.borderRadius ?? DEFAULT_SETTINGS.borderRadius
)
merged.chromeStyle = normalizeChromeStyle(raw.chromeStyle ?? DEFAULT_SETTINGS.chromeStyle)
merged.terminalTheme = normalizeTerminalThemePref(
raw.terminalTheme ?? DEFAULT_SETTINGS.terminalTheme
)
@@ -496,11 +590,20 @@ let listRefreshTimer = null
export function applySettings(s = loadSettings()) {
document.body.dataset.density = s.density || 'comfortable'
document.body.dataset.badgeMode = s.badgeMode || 'alerts'
document.body.dataset.accent = s.accent || 'teal'
document.body.dataset.accent = normalizeAccent(s.accent)
document.body.dataset.uiScale = normalizeUiScale(s.uiScale)
document.body.dataset.radius = normalizeBorderRadius(s.borderRadius)
document.body.dataset.chrome = normalizeChromeStyle(s.chromeStyle)
document.body.dataset.reduceMotion = s.reduceMotion ? '1' : '0'
document.body.dataset.refreshStamp = s.showRefreshStamp === false ? '0' : '1'
applyColorTheme(s.theme || 'dark')
startListAutoRefresh(s.refreshSeconds)
try {
ensureAccentSwatches()
syncAccentSwatches(normalizeAccent(s.accent))
} catch {
// ignore (DOM not ready)
}
// Fast path for uiUtils / reconnect handlers (avoid re-parsing localStorage every event)
try {
@@ -2145,6 +2248,71 @@ function setCheckbox(id, checked) {
if (el) el.checked = Boolean(checked)
}
/**
* Build accent swatch buttons once (Settings Appearance).
* Swatches keep the hidden/select #settings-accent in sync for live apply.
*/
export function ensureAccentSwatches() {
const host = document.getElementById('settings-accent-swatches')
const select = document.getElementById('settings-accent')
if (!host || host.dataset.ready === '1') return
// Populate select options from presets (keeps HTML/select in lockstep with JS)
if (select && select.tagName === 'SELECT') {
const current = normalizeAccent(select.value)
select.replaceChildren()
for (const id of ACCENT_IDS) {
const opt = document.createElement('option')
opt.value = id
opt.textContent = ACCENT_PRESETS[id].label + (id === 'teal' ? ' (default)' : '')
select.appendChild(opt)
}
select.value = current
}
host.replaceChildren()
host.setAttribute('role', 'radiogroup')
host.setAttribute('aria-label', 'Accent color')
for (const id of ACCENT_IDS) {
const preset = ACCENT_PRESETS[id]
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'pd-accent-swatch'
btn.dataset.accentValue = id
btn.title = preset.label
btn.setAttribute('aria-label', preset.label)
btn.setAttribute('role', 'radio')
btn.style.setProperty('--swatch', preset.primary)
btn.style.setProperty('--swatch-2', preset.secondary)
btn.addEventListener('click', () => {
if (select) {
select.value = id
select.dispatchEvent(new Event('change', { bubbles: true }))
}
syncAccentSwatches(id)
})
host.appendChild(btn)
}
host.dataset.ready = '1'
}
/**
* @param {string} accentId
*/
export function syncAccentSwatches(accentId) {
const id = normalizeAccent(accentId)
const host = document.getElementById('settings-accent-swatches')
if (!host) return
host.querySelectorAll('.pd-accent-swatch').forEach((el) => {
const on = el.dataset.accentValue === id
el.classList.toggle('is-active', on)
el.setAttribute('aria-checked', on ? 'true' : 'false')
})
const select = document.getElementById('settings-accent')
if (select && select.value !== id) select.value = id
}
/**
* Switch settings subtab.
* @param {string} tab
@@ -2367,13 +2535,15 @@ export function readSettingsForm() {
const density = ['comfortable', 'compact', 'spacious'].includes(densityRaw)
? densityRaw
: 'comfortable'
const accentRaw = document.getElementById('settings-accent')?.value || 'teal'
const accent = ['teal', 'cyan', 'violet'].includes(accentRaw) ? accentRaw : 'teal'
const accent = normalizeAccent(document.getElementById('settings-accent')?.value)
const theme = normalizeThemePreference(document.getElementById('settings-theme')?.value)
return {
density,
accent,
theme,
uiScale: normalizeUiScale(document.getElementById('settings-ui-scale')?.value),
borderRadius: normalizeBorderRadius(document.getElementById('settings-radius')?.value),
chromeStyle: normalizeChromeStyle(document.getElementById('settings-chrome')?.value),
reduceMotion: document.getElementById('settings-reduce-motion')?.value === '1',
showRefreshStamp: document.getElementById('settings-refresh-stamp')?.value !== '0',
sidebarCollapsed: document.getElementById('settings-sidebar')?.value === 'collapsed',
@@ -2756,10 +2926,15 @@ 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-accent', normalizeAccent(s.accent))
setSelectValue('settings-ui-scale', normalizeUiScale(s.uiScale))
setSelectValue('settings-radius', normalizeBorderRadius(s.borderRadius))
setSelectValue('settings-chrome', normalizeChromeStyle(s.chromeStyle))
setSelectValue('settings-reduce-motion', s.reduceMotion ? '1' : '0')
setSelectValue('settings-refresh-stamp', s.showRefreshStamp === false ? '0' : '1')
setSelectValue('settings-sidebar', s.sidebarCollapsed ? 'collapsed' : 'expanded')
ensureAccentSwatches()
syncAccentSwatches(normalizeAccent(s.accent))
setSelectValue('settings-confirm', s.confirmDestructive === false ? '0' : '1')
setSelectValue('settings-refresh', s.refreshSeconds ?? 10)
setSelectValue('settings-job-dismiss', s.jobSuccessDismissMs ?? 3000)
+70 -2
View File
@@ -58,18 +58,86 @@ html[data-theme='light'] {
--pd-border: var(--border-color);
}
/* Accent variants on light: deeper hues for contrast */
/* Accent variants on light: deeper hues for contrast on white surfaces */
html[data-theme='light'] body[data-accent='teal'] {
--accent-primary: #0f766e;
--accent-secondary: #0d9488;
--accent-glow: rgba(15, 118, 110, 0.2);
}
html[data-theme='light'] body[data-accent='cyan'] {
--accent-primary: #0e7490;
--accent-secondary: #0891b2;
--accent-glow: rgba(14, 116, 144, 0.22);
}
html[data-theme='light'] body[data-accent='emerald'] {
--accent-primary: #047857;
--accent-secondary: #059669;
--accent-glow: rgba(4, 120, 87, 0.2);
}
html[data-theme='light'] body[data-accent='blue'] {
--accent-primary: #1d4ed8;
--accent-secondary: #2563eb;
--accent-glow: rgba(29, 78, 216, 0.18);
}
html[data-theme='light'] body[data-accent='indigo'] {
--accent-primary: #4338ca;
--accent-secondary: #4f46e5;
--accent-glow: rgba(67, 56, 202, 0.18);
}
html[data-theme='light'] body[data-accent='violet'] {
--accent-primary: #6d28d9;
--accent-secondary: #7c3aed;
--accent-glow: rgba(109, 40, 217, 0.2);
}
html[data-theme='light'] body[data-accent='fuchsia'] {
--accent-primary: #a21caf;
--accent-secondary: #c026d3;
--accent-glow: rgba(162, 28, 175, 0.18);
}
html[data-theme='light'] body[data-accent='rose'] {
--accent-primary: #be123c;
--accent-secondary: #e11d48;
--accent-glow: rgba(190, 18, 60, 0.18);
}
html[data-theme='light'] body[data-accent='amber'] {
--accent-primary: #b45309;
--accent-secondary: #d97706;
--accent-glow: rgba(180, 83, 9, 0.18);
}
html[data-theme='light'] body[data-accent='orange'] {
--accent-primary: #c2410c;
--accent-secondary: #ea580c;
--accent-glow: rgba(194, 65, 12, 0.18);
}
html[data-theme='light'] body[data-accent='sky'] {
--accent-primary: #0369a1;
--accent-secondary: #0284c7;
--accent-glow: rgba(3, 105, 161, 0.18);
}
html[data-theme='light'] body[data-accent='lime'] {
--accent-primary: #4d7c0f;
--accent-secondary: #65a30d;
--accent-glow: rgba(77, 124, 15, 0.18);
}
html[data-theme='light'] body[data-chrome='solid'] #titlebar {
background: #ffffff;
}
html[data-theme='light'] body[data-chrome='flat'] #titlebar {
background: #f3f5f8;
}
html[data-theme='light'] body[data-chrome='flat'] #sidebar {
background: #f3f5f8;
}
html[data-theme='light'] .pd-accent-swatch {
border-color: rgba(15, 23, 42, 0.12);
}
html[data-theme='light'] .pd-accent-swatch.is-active {
border-color: #0f172a;
box-shadow:
0 0 0 2px #fff,
0 0 0 4px var(--swatch, var(--accent-primary));
}
/* ─── Base chrome ─── */
html[data-theme='light'] body {