Add theme presets, gradient backgrounds, and macOS-style window chrome

Replaces the old light/dark toggle with 10 fixed color themes (plus
Black & Grey and White) that drive a coordinated gradient palette across
the whole admin UI, a Google Fonts picker, and a native-page dark-mode
stylesheet that now covers modals and plugin/theme card grids. Fixes
several background/gradient seams where the sidebar, toolbar, and content
frame were each painting their own independent copy of the page gradient
instead of sharing one continuous plane.

Also adds a macOS-style traffic-light title bar to every page (close,
minimize, maximize) and a persistent bottom taskbar so a minimized page
can be restored from anywhere, plus a Plugins management page.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
nixpanel
2026-07-03 11:49:15 -05:00
co-authored by Claude Sonnet 5
parent db07e21f9e
commit 4a547d2c50
26 changed files with 1816 additions and 223 deletions
+17 -11
View File
@@ -2,6 +2,7 @@
import { ref, reactive, computed, watch } from 'vue';
import { settingsCategories } from './settingsConfig.js';
import SettingsField from './components/SettingsField.vue';
import { applyThemePalette, applyFontFamily } from './applyLiveTheme.js';
const props = defineProps({
serverData: { type: Object, required: true },
@@ -51,10 +52,17 @@ const optionsFor = (field) => {
return field.options || [];
};
// Live dark-mode preview.
// Live preview — applied as soon as a picker changes, not just after
// Save + reload.
watch(
() => settings.dark_mode,
(v) => document.documentElement.classList.toggle('aurora-dark', !!v)
() => settings.theme_preset,
(preset) => applyThemePalette(preset || 'indigo'),
{ immediate: true }
);
watch(
() => settings.font_family,
(family) => applyFontFamily(family),
{ immediate: true }
);
// Save -------------------------------------------------------------------
@@ -189,16 +197,16 @@ const importSettings = (e) => {
margin: -32px; /* cancel the frame padding */
padding: 22px; /* inset the two boxes; the frame's right gutter adds the rest */
box-sizing: border-box;
/* Darker backdrop so the two boxes read as distinct panels. */
background: var(--aurora-app-bg);
/* No background here — html/body already paint the page gradient
(shell-frame.css); repainting the same var(--aurora-app-bg) here was
just a redundant duplicate layer on top of it. */
}
/* Left nav — its own box */
/* Left nav — no background/border of its own; the page gradient shows
straight through so it reads as one surface instead of a boxed panel. */
.settings__nav {
width: 340px;
flex-shrink: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 24px 20px;
box-sizing: border-box;
@@ -252,11 +260,9 @@ const importSettings = (e) => {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Right content — its own box */
/* Right content — no background/border of its own, same reasoning. */
.settings__content {
flex: 1; min-width: 0;
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: 16px;
padding: 28px 32px;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared logic for applying the theme/font settings to the live document —
* used both for the settings page's live preview (Settings.vue) and the
* real per-page-load application (SiteShell.vue::onMounted).
*/
import { resolvePreset, buildPalette } from './themePresets.js';
export function applyThemePalette(themePresetId) {
const palette = buildPalette(resolvePreset(themePresetId));
const root = document.documentElement.style;
Object.entries(palette).forEach(([prop, value]) => root.setProperty(prop, value));
}
export function applyFontFamily(family) {
if (!family) return;
const linkId = 'aurora-google-font';
let link = document.getElementById(linkId);
if (!link) {
link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
document.head.appendChild(link);
}
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;500;600;700&display=swap`;
document.documentElement.style.setProperty('--aurora-font-family', `'${family}', sans-serif`);
}
@@ -1,6 +1,10 @@
<script setup>
import { computed, reactive } from 'vue';
import { computed, reactive, ref } from 'vue';
import Toggle from '@/components/Toggle.vue';
import { themePresets, buildPalette } from '@/pages/settings/themePresets.js';
import { googleFonts } from '@/pages/settings/googleFonts.js';
const swatchPalette = (preset) => buildPalette(preset);
const props = defineProps({
field: { type: Object, required: true },
@@ -66,6 +70,24 @@ const pickImage = () => {
});
frame.open();
};
// Font picker (searchable Google Font dropdown) ----------------------------
const fontQuery = ref('');
const fontOpen = ref(false);
const filteredFonts = computed(() => {
const q = fontQuery.value.trim().toLowerCase();
const list = q ? googleFonts.filter((f) => f.toLowerCase().includes(q)) : googleFonts;
return list.slice(0, 40);
});
const pickFont = (name) => {
emit('update:modelValue', name);
fontQuery.value = name;
fontOpen.value = false;
};
const openFontList = () => {
fontQuery.value = props.modelValue || '';
fontOpen.value = true;
};
</script>
<template>
@@ -169,6 +191,59 @@ const pickImage = () => {
</p>
</div>
<!-- Theme picker (10 fixed color presets, whole-UI preview) -->
<div v-else-if="field.type === 'theme-picker'" class="field__theme">
<button
v-for="preset in themePresets"
:key="preset.id"
type="button"
class="field__theme-card"
:class="{ 'field__theme-card--active': (val || field.default) === preset.id }"
@click="val = preset.id"
>
<span
class="field__theme-swatch"
:style="{
background: swatchPalette(preset)['--aurora-app-bg'],
borderColor: swatchPalette(preset)['--aurora-border'],
}"
>
<span
class="field__theme-swatch-accent"
:style="{ background: swatchPalette(preset)['--aurora-accent'] }"
/>
</span>
<span class="field__theme-name">{{ preset.label }}</span>
</button>
</div>
<!-- Font picker (searchable Google Font dropdown) -->
<div v-else-if="field.type === 'font-picker'" class="field__font">
<input
type="text"
class="field__input"
placeholder="Search Google Fonts…"
:value="fontOpen ? fontQuery : (val || 'Default')"
@focus="openFontList"
@input="fontQuery = $event.target.value"
@blur="() => setTimeout(() => (fontOpen = false), 150)"
/>
<div v-if="fontOpen" class="field__font-list">
<button
v-for="name in filteredFonts"
:key="name"
type="button"
class="field__font-item"
:class="{ 'field__font-item--active': val === name }"
:style="{ fontFamily: `'${name}', sans-serif` }"
@mousedown.prevent="pickFont(name)"
>
{{ name }}
</button>
<p v-if="!filteredFonts.length" class="field__multi-empty">No fonts match.</p>
</div>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
@@ -309,6 +384,49 @@ const pickImage = () => {
.field__sites-user { color: var(--aurora-text-muted); }
.field__sites-help { margin: 10px 0 0; font-size: 0.75rem; color: var(--aurora-text-muted); }
/* Theme picker */
.field__theme { display: flex; flex-wrap: wrap; gap: 10px; width: 100%; }
.field__theme-card {
display: flex; flex-direction: column; align-items: center; gap: 6px;
width: 76px; padding: 10px 6px;
border: 1px solid var(--aurora-border);
border-radius: 10px;
background: var(--aurora-bg-subtle);
cursor: pointer;
}
.field__theme-card--active { border-color: var(--aurora-accent); box-shadow: 0 0 0 1px var(--aurora-accent); }
.field__theme-swatch {
width: 36px; height: 36px; border-radius: 10px;
border: 1px solid var(--aurora-card-border);
display: flex; align-items: flex-end; justify-content: flex-end;
padding: 4px;
box-sizing: border-box;
}
.field__theme-swatch-accent {
width: 10px; height: 10px; border-radius: 50%;
}
.field__theme-name { font-size: 0.6875rem; color: var(--aurora-text-muted); }
/* Font picker */
.field__font { position: relative; width: 100%; max-width: 320px; }
.field__font-list {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
max-height: 260px; overflow-y: auto; z-index: 20;
background: var(--aurora-card-bg);
border: 1px solid var(--aurora-border);
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.field__font-item {
display: block; width: 100%; text-align: left;
border: none; background: none; cursor: pointer;
padding: 8px 10px; border-radius: 7px;
font-size: 0.875rem; color: var(--aurora-text);
}
.field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Curated list of popular Google Font family names for the font-picker
* field. Not the full ~1700-family catalog — a representative set across
* sans, serif, display, and monospace so the picker has something useful
* to search without shipping/maintaining a giant metadata blob. Each
* family is requested from Google's CSS2 API on demand (see
* SiteShell.vue::applyFontFamily), never bundled as font files.
*/
export const googleFonts = [
'Inter',
'Roboto',
'Open Sans',
'Lato',
'Montserrat',
'Poppins',
'Nunito',
'Nunito Sans',
'Source Sans 3',
'Work Sans',
'Rubik',
'Manrope',
'DM Sans',
'Karla',
'Mulish',
'Figtree',
'Plus Jakarta Sans',
'Outfit',
'Space Grotesk',
'Sora',
'Albert Sans',
'Urbanist',
'Public Sans',
'IBM Plex Sans',
'Barlow',
'Cabin',
'Hanken Grotesk',
'Lexend',
'Jost',
'Red Hat Display',
'Playfair Display',
'Merriweather',
'Lora',
'PT Serif',
'Crimson Text',
'Source Serif 4',
'Libre Baskerville',
'Cormorant Garamond',
'EB Garamond',
'Bitter',
'Domine',
'Fraunces',
'Newsreader',
'Spectral',
'Frank Ruhl Libre',
'Josefin Sans',
'Quicksand',
'Comfortaa',
'Raleway',
'Oswald',
'Bebas Neue',
'Archivo',
'Archivo Black',
'Anton',
'Righteous',
'Fjalla One',
'Abril Fatface',
'Cormorant',
'Zilla Slab',
'IBM Plex Mono',
'JetBrains Mono',
'Space Mono',
'Roboto Mono',
'Fira Code',
'Source Code Pro',
'Courier Prime',
'Instrument Sans',
'Instrument Serif',
'Bricolage Grotesque',
];
+9 -9
View File
@@ -94,17 +94,17 @@ export const settingsCategories = [
icon: 'dashicons-admin-appearance',
fields: [
{
key: 'dark_mode',
type: 'toggle',
label: 'Dark mode',
description: 'Use the dark interface theme across the admin.',
key: 'theme_preset',
type: 'theme-picker',
label: 'Theme',
description: 'Pick a color for the whole admin interface.',
default: 'indigo',
},
{
key: 'accent_color',
type: 'color',
label: 'Accent color',
description: 'Primary accent color used throughout the interface.',
default: '#4772fa',
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'Google Font used across the Aurora interface.',
},
],
},
+103
View File
@@ -0,0 +1,103 @@
/**
* Fixed set of theme presets for the theme-picker field. No custom color
* picker — exactly these 10 predetermined options. Each preset is a hue;
* the main background gradient is mostly two vivid, saturated stops of
* that hue (like FleekDash's "Fresh"/"Sunrise" palette bars), only
* dipping to a near-black anchor in the last stretch — not a 50/50
* dark-to-vivid split, which reads washed out rather than "poppy".
* Cards/borders/text derive as solid, more muted shades of the primary
* hue so the gradient stays a deliberate hero background rather than
* making every surface busy.
*/
export const themePresets = [
{ id: 'indigo', label: 'Indigo', hue: 235 },
{ id: 'sky', label: 'Sky', hue: 205 },
{ id: 'teal', label: 'Teal', hue: 175 },
{ id: 'forest', label: 'Forest', hue: 140 },
{ id: 'lime', label: 'Lime', hue: 95 },
{ id: 'amber', label: 'Amber', hue: 45 },
{ id: 'orange', label: 'Orange', hue: 22 },
{ id: 'rose', label: 'Rose', hue: 345 },
{ id: 'berry', label: 'Berry', hue: 320 },
{ id: 'violet', label: 'Violet', hue: 265 },
// Two flat neutrals, no gradient — for anyone who finds the color
// themes above too much. Same values the shell originally shipped with
// before theme presets existed (zinc dark / zinc light).
{
id: 'mono',
label: 'Black & Grey',
palette: {
'--aurora-app-bg': '#09090b',
'--aurora-app-bg-solid': '#09090b',
'--aurora-bg': '#18181b',
'--aurora-card-bg': '#1f1f23',
'--aurora-card-bg-deep': '#131316',
'--aurora-bg-subtle': '#27272a',
'--aurora-text': '#f4f4f5',
'--aurora-text-muted': '#a1a1aa',
'--aurora-border': '#27272a',
'--aurora-card-border': '#2e2e33',
'--aurora-frame-bg': '#1c1c20',
'--aurora-frame-border': '#34343b',
'--aurora-accent': 'rgb(125 158 255)',
'--aurora-accent-soft': 'rgb(125 158 255 / 0.14)',
},
},
{
id: 'white',
label: 'White',
palette: {
'--aurora-app-bg': '#fafafa',
'--aurora-app-bg-solid': '#fafafa',
'--aurora-bg': '#ffffff',
'--aurora-card-bg': '#fafafa',
'--aurora-card-bg-deep': '#f0f0f2',
'--aurora-bg-subtle': '#f4f4f5',
'--aurora-text': '#18181b',
'--aurora-text-muted': '#71717a',
'--aurora-border': '#e4e4e7',
'--aurora-card-border': '#ececef',
'--aurora-frame-bg': '#ffffff',
'--aurora-frame-border': '#e0e0e3',
'--aurora-accent': 'rgb(71 114 250)',
'--aurora-accent-soft': 'rgb(71 114 250 / 0.1)',
},
},
];
const hsl = (h, s, l, a) =>
a === undefined ? `hsl(${h} ${s}% ${l}%)` : `hsl(${h} ${s}% ${l}% / ${a})`;
/** The full set of --aurora-* color tokens for a preset. */
export function buildPalette(preset) {
if (preset.palette) return preset.palette;
const { hue } = preset;
// Two vivid, bright stops of the same hue family carry most of the bar
// (0% → 55%); the near-black anchor only takes over in the final
// stretch (100%). That's the "mostly color, dark tail" look the
// FleekDash palette bars have — full saturation start to middle keeps
// it feeling poppy instead of muddy.
const hue2 = (hue + 35) % 360;
const solidAppBg = hsl(hue, 45, 16);
return {
'--aurora-app-bg': `linear-gradient(135deg, ${hsl(hue, 78, 48)} 0%, ${hsl(hue2, 78, 45)} 55%, ${hsl(hue, 32, 9)} 100%)`,
'--aurora-app-bg-solid': solidAppBg,
'--aurora-bg': hsl(hue, 40, 20),
'--aurora-card-bg': hsl(hue, 36, 24),
'--aurora-card-bg-deep': hsl(hue, 38, 14),
'--aurora-bg-subtle': hsl(hue, 32, 28),
'--aurora-text': hsl(hue, 20, 97),
'--aurora-text-muted': hsl(hue, 14, 74),
'--aurora-border': hsl(hue, 28, 30),
'--aurora-card-border': hsl(hue, 26, 33),
'--aurora-frame-bg': hsl(hue, 34, 22),
'--aurora-frame-border': hsl(hue, 26, 33),
'--aurora-accent': hsl(hue, 85, 62),
'--aurora-accent-soft': hsl(hue, 85, 62, 0.16),
};
}
/** Resolve a theme_preset id to the preset object (falls back to the first preset). */
export function resolvePreset(themePresetId) {
return themePresets.find((p) => p.id === themePresetId) || themePresets[0];
}