6 new themes + grouped theme picker dropdown

Dark themes:
- Midnight: ultra-minimal, near-black, zero effects
- Hacker: green-on-black terminal, monospace, CRT scanlines
- Neon: hot pink + cyan on purple-black, synthwave, heavy glow

Light themes:
- Paper: warm cream/sepia, serif fonts, book-like
- Arctic: cool whites and icy blues, crisp and modern
- Solarized: Ethan Schoonover's solarized-light palette

Theme picker:
- Replaced simple dark/light toggle with grouped dropdown
- Themes organized by Dark / Light sections with active checkmark
- Remembers last dark and light theme separately for quick toggle
- Theme metadata now includes variant field for proper grouping
- Custom themes default to dark variant
This commit is contained in:
xpltd 2026-03-22 00:51:00 -05:00
parent 4b766bb0e7
commit 2a8f59fecc
10 changed files with 424 additions and 36 deletions

View file

@ -1,17 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
const theme = useThemeStore()
const showPicker = ref(false)
function selectTheme(id: string) {
theme.setTheme(id)
showPicker.value = false
}
function closePicker() {
showPicker.value = false
}
</script>
<template>
<div class="theme-picker-wrapper" @mouseleave="closePicker">
<button
class="dark-mode-toggle"
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggleDarkMode()"
aria-label="Toggle dark/light mode"
class="theme-toggle-btn"
:title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
@click="showPicker = !showPicker"
aria-label="Theme picker"
>
<!-- Sun icon (shown in dark mode click to go light) -->
<!-- Sun icon (dark mode active) -->
<svg v-if="theme.isDark" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
@ -23,15 +35,54 @@ const theme = useThemeStore()
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- Moon icon (shown in light mode click to go dark) -->
<!-- Moon icon (light mode active) -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<Transition name="fade">
<div v-if="showPicker" class="theme-dropdown">
<div class="theme-group">
<div class="theme-group-label">Dark</div>
<button
v-for="t in theme.darkThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
<div class="theme-divider"></div>
<div class="theme-group">
<div class="theme-group-label">Light</div>
<button
v-for="t in theme.lightThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.dark-mode-toggle {
.theme-picker-wrapper {
position: relative;
}
.theme-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
@ -46,7 +97,76 @@ const theme = useThemeStore()
padding: 0;
}
.dark-mode-toggle:hover {
.theme-toggle-btn:hover {
color: var(--color-accent);
}
.theme-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 180px;
z-index: 100;
overflow: hidden;
}
.theme-group-label {
padding: 8px 14px 4px;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.theme-divider {
height: 1px;
background: var(--color-border);
margin: 4px 0;
}
.theme-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 14px;
background: transparent;
color: var(--color-text);
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-family: var(--font-ui);
text-align: left;
transition: background var(--transition-fast);
}
.theme-option:hover {
background: var(--color-surface-hover);
}
.theme-option.active {
color: var(--color-accent);
}
.theme-check {
color: var(--color-accent);
font-weight: bold;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View file

@ -8,6 +8,12 @@ import './assets/base.css'
import './themes/cyberpunk.css'
import './themes/dark.css'
import './themes/light.css'
import './themes/midnight.css'
import './themes/hacker.css'
import './themes/neon.css'
import './themes/paper.css'
import './themes/arctic.css'
import './themes/solarized.css'
import App from './App.vue'

View file

@ -17,15 +17,24 @@ export interface ThemeMeta {
author?: string
description?: string
builtin: boolean
variant: 'dark' | 'light'
}
const STORAGE_KEY = 'mrip-theme'
const DEFAULT_THEME = 'cyberpunk'
const BUILTIN_THEMES: ThemeMeta[] = [
{ id: 'cyberpunk', name: 'Cyberpunk', author: 'media.rip()', description: 'Electric blue + orange, scanlines, grid overlay', builtin: true },
{ id: 'dark', name: 'Dark', author: 'media.rip()', description: 'Clean neutral dark theme', builtin: true },
{ id: 'light', name: 'Light', author: 'media.rip()', description: 'Clean light theme for daylight use', builtin: true },
// Dark themes
{ id: 'cyberpunk', name: 'Cyberpunk', author: 'media.rip()', description: 'Electric blue + orange, scanlines, grid overlay', builtin: true, variant: 'dark' },
{ id: 'dark', name: 'Dark', author: 'media.rip()', description: 'Clean neutral dark theme', builtin: true, variant: 'dark' },
{ id: 'midnight', name: 'Midnight', author: 'media.rip()', description: 'Ultra-minimal, near-black, zero effects', builtin: true, variant: 'dark' },
{ id: 'hacker', name: 'Hacker', author: 'media.rip()', description: 'Green-on-black terminal aesthetic', builtin: true, variant: 'dark' },
{ id: 'neon', name: 'Neon', author: 'media.rip()', description: 'Hot pink + cyan on deep purple, synthwave vibes', builtin: true, variant: 'dark' },
// Light themes
{ id: 'light', name: 'Light', author: 'media.rip()', description: 'Clean light theme for daylight use', builtin: true, variant: 'light' },
{ id: 'paper', name: 'Paper', author: 'media.rip()', description: 'Warm cream and sepia, book-like', builtin: true, variant: 'light' },
{ id: 'arctic', name: 'Arctic', author: 'media.rip()', description: 'Cool whites and icy blues, crisp and sharp', builtin: true, variant: 'light' },
{ id: 'solarized', name: 'Solarized', author: 'media.rip()', description: 'Solarized Light — easy on the eyes', builtin: true, variant: 'light' },
]
export const useThemeStore = defineStore('theme', () => {
@ -33,8 +42,14 @@ export const useThemeStore = defineStore('theme', () => {
const customThemes = ref<ThemeMeta[]>([])
const customThemeCSS = ref<Map<string, string>>(new Map())
/** Whether the current theme is a dark variant (cyberpunk and dark are dark; light is light). */
const isDark = computed(() => currentTheme.value !== 'light')
/** Whether the current theme is a dark variant. */
const isDark = computed(() => {
const meta = allThemes.value.find(t => t.id === currentTheme.value)
return meta ? meta.variant === 'dark' : true
})
const darkThemes = computed(() => allThemes.value.filter(t => t.variant === 'dark'))
const lightThemes = computed(() => allThemes.value.filter(t => t.variant === 'light'))
const allThemes = computed<ThemeMeta[]>(() => [
...BUILTIN_THEMES,
@ -64,11 +79,13 @@ export const useThemeStore = defineStore('theme', () => {
*/
function toggleDarkMode(): void {
if (isDark.value) {
setTheme('light')
// Switch to last used light theme, or first available
const lastLight = localStorage.getItem(STORAGE_KEY + '-light') || 'light'
setTheme(lastLight)
} else {
// Return to the last dark theme, defaulting to cyberpunk
const lastDark = localStorage.getItem(STORAGE_KEY + '-dark') || DEFAULT_THEME
setTheme(lastDark === 'light' ? DEFAULT_THEME : lastDark)
setTheme(lastDark)
}
}
@ -82,8 +99,11 @@ export const useThemeStore = defineStore('theme', () => {
currentTheme.value = themeId
localStorage.setItem(STORAGE_KEY, themeId)
// Remember the last dark theme for toggle
if (themeId !== 'light') {
const meta = allThemes.value.find(t => t.id === themeId)
if (meta?.variant === 'dark') {
localStorage.setItem(STORAGE_KEY + '-dark', themeId)
} else {
localStorage.setItem(STORAGE_KEY + '-light', themeId)
}
_apply(themeId)
}
@ -104,6 +124,7 @@ export const useThemeStore = defineStore('theme', () => {
author: t.author,
description: t.description,
builtin: false,
variant: t.variant || 'dark', // default custom themes to dark
}))
// If saved theme is a custom theme, validate it still exists
@ -159,6 +180,8 @@ export const useThemeStore = defineStore('theme', () => {
currentTheme,
customThemes,
allThemes,
darkThemes,
lightThemes,
currentMeta,
isDark,
init,

View file

@ -73,10 +73,13 @@ describe('theme store', () => {
expect(store.currentTheme).toBe('cyberpunk')
})
it('lists 3 built-in themes', () => {
it('lists 9 built-in themes', () => {
const store = useThemeStore()
expect(store.allThemes).toHaveLength(3)
expect(store.allThemes.map(t => t.id)).toEqual(['cyberpunk', 'dark', 'light'])
expect(store.allThemes).toHaveLength(9)
expect(store.allThemes.map(t => t.id)).toEqual([
'cyberpunk', 'dark', 'midnight', 'hacker', 'neon',
'light', 'paper', 'arctic', 'solarized',
])
})
it('all built-in themes are marked builtin: true', () => {

View file

@ -0,0 +1,35 @@
/* media.rip() Arctic Theme
*
* Cool whites, icy blues, crisp edges.
* Modern, clean, and sharp like fresh snow.
*/
:root[data-theme="arctic"] {
--color-bg: #f0f4f8;
--color-surface: #ffffff;
--color-surface-hover: #e8eef4;
--color-border: #c8d6e5;
--color-text: #1a2a3a;
--color-text-muted: #5a7a94;
--color-accent: #0088cc;
--color-accent-hover: #006da6;
--color-accent-secondary: #0066aa;
--color-success: #00a878;
--color-warning: #e8a317;
--color-error: #d63031;
--font-display: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--effect-scanlines: none;
--effect-grid: none;
--effect-grid-size: 0px 0px;
--effect-glow: none;
--shadow-sm: 0 1px 3px rgba(26, 42, 58, 0.06);
--shadow-md: 0 4px 12px rgba(26, 42, 58, 0.08);
--shadow-lg: 0 8px 24px rgba(26, 42, 58, 0.12);
--shadow-glow: none;
}

View file

@ -0,0 +1,43 @@
/* media.rip() Hacker Theme
*
* Green-on-black terminal aesthetic. Monospace everything,
* phosphor glow, CRT scanlines. You're in the Matrix.
*/
:root[data-theme="hacker"] {
--color-bg: #0a0a0a;
--color-surface: #0f1a0f;
--color-surface-hover: #152215;
--color-border: #1a3a1a;
--color-text: #33ff33;
--color-text-muted: #1a8c1a;
--color-accent: #00ff41;
--color-accent-hover: #33ff66;
--color-accent-secondary: #ffcc00;
--color-success: #00ff41;
--color-warning: #ffcc00;
--color-error: #ff3333;
--font-ui: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Courier New', monospace;
--font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Courier New', monospace;
--font-display: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Courier New', monospace;
--effect-scanlines: repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(0, 255, 65, 0.03) 1px,
rgba(0, 255, 65, 0.03) 2px
);
--effect-grid: none;
--effect-grid-size: 0px 0px;
--effect-glow: 0 0 12px rgba(0, 255, 65, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 15px rgba(0, 255, 65, 0.12);
}

View file

@ -0,0 +1,40 @@
/* media.rip() Midnight Theme
*
* Ultra-minimal dark theme. Near-black backgrounds,
* muted steel blue accents, zero visual effects.
* For people who want the UI to disappear.
*/
:root[data-theme="midnight"] {
--color-bg: #060608;
--color-surface: #0e0e12;
--color-surface-hover: #16161c;
--color-border: #1c1c24;
--color-text: #c8ccd0;
--color-text-muted: #5c6370;
--color-accent: #6b8aaf;
--color-accent-hover: #8aa4c4;
--color-accent-secondary: #af6b8a;
--color-success: #5faa7c;
--color-warning: #c4a35a;
--color-error: #c45a5a;
--font-display: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--effect-scanlines: none;
--effect-grid: none;
--effect-grid-size: 0px 0px;
--effect-glow: none;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.7);
--shadow-glow: none;
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 8px;
}

View file

@ -0,0 +1,42 @@
/* media.rip() Neon Theme
*
* Hot pink + cyan on deep purple-black. Vibrant, edgy,
* synthwave-inspired. Glow effects cranked up.
*/
:root[data-theme="neon"] {
--color-bg: #0d0014;
--color-surface: #150022;
--color-surface-hover: #1e0033;
--color-border: #2a0044;
--color-text: #f0e0ff;
--color-text-muted: #9966bb;
--color-accent: #ff2d95;
--color-accent-hover: #ff5cae;
--color-accent-secondary: #00e5ff;
--color-success: #00e676;
--color-warning: #ffab00;
--color-error: #ff1744;
--font-display: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
--effect-scanlines: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(255, 45, 149, 0.04) 3px,
rgba(255, 45, 149, 0.04) 4px
);
--effect-grid: linear-gradient(rgba(0, 229, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 229, 255, 0.02) 1px, transparent 1px);
--effect-grid-size: 40px 40px;
--effect-glow: 0 0 25px rgba(255, 45, 149, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(13, 0, 20, 0.6);
--shadow-lg: 0 8px 24px rgba(13, 0, 20, 0.8);
--shadow-glow: 0 0 20px rgba(255, 45, 149, 0.15);
}

View file

@ -0,0 +1,40 @@
/* media.rip() Paper Theme
*
* Warm cream and sepia tones. Book-like reading feel.
* Soft, easy on the eyes in bright environments.
*/
:root[data-theme="paper"] {
--color-bg: #f5f0e8;
--color-surface: #fffdf7;
--color-surface-hover: #f0ebe0;
--color-border: #d6cebf;
--color-text: #2c2416;
--color-text-muted: #7a705e;
--color-accent: #b85c38;
--color-accent-hover: #a04e2e;
--color-accent-secondary: #2e6b5e;
--color-success: #3a7d44;
--color-warning: #c48a1a;
--color-error: #b83838;
--font-ui: 'Georgia', 'Palatino Linotype', 'Book Antiqua', serif;
--font-display: 'Georgia', 'Palatino Linotype', 'Book Antiqua', serif;
--effect-scanlines: none;
--effect-grid: none;
--effect-grid-size: 0px 0px;
--effect-glow: none;
--shadow-sm: 0 1px 2px rgba(44, 36, 22, 0.06);
--shadow-md: 0 3px 8px rgba(44, 36, 22, 0.08);
--shadow-lg: 0 8px 20px rgba(44, 36, 22, 0.1);
--shadow-glow: none;
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 10px;
}

View file

@ -0,0 +1,36 @@
/* media.rip() Solarized Light Theme
*
* Based on Ethan Schoonover's Solarized palette.
* Developer favorite designed for extended use
* with carefully selected contrast ratios.
*/
:root[data-theme="solarized"] {
--color-bg: #fdf6e3;
--color-surface: #eee8d5;
--color-surface-hover: #e8e1cc;
--color-border: #d3cbaf;
--color-text: #586e75;
--color-text-muted: #93a1a1;
--color-accent: #268bd2;
--color-accent-hover: #1a6da3;
--color-accent-secondary: #d33682;
--color-success: #859900;
--color-warning: #b58900;
--color-error: #dc322f;
--font-display: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
--effect-scanlines: none;
--effect-grid: none;
--effect-grid-size: 0px 0px;
--effect-glow: none;
--shadow-sm: 0 1px 2px rgba(88, 110, 117, 0.08);
--shadow-md: 0 3px 8px rgba(88, 110, 117, 0.1);
--shadow-lg: 0 8px 20px rgba(88, 110, 117, 0.12);
--shadow-glow: none;
}