feat: add dark theme support with CSS custom properties and Naive UI integration

Implement runtime theme switching using CSS custom properties delegated through SCSS variables, with light/dark/system modes, FOUC prevention, sidebar toggle, and settings selector. Add theme-aware video assets for sidebar and chat thinking indicator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 23:13:04 +08:00
parent 076a7c2a38
commit b5aeb876b8
32 changed files with 465 additions and 126 deletions
@@ -331,7 +331,7 @@ function isImage(type: string): boolean {
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: #fff;
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -394,8 +394,8 @@ function isImage(type: string): boolean {
// Drag-over state
.input-wrapper.drag-over {
border-color: #4a90d9;
border-color: var(--accent-info);
border-style: dashed;
background-color: rgba(74, 144, 217, 0.04);
background-color: rgba(var(--accent-info-rgb), 0.04);
}
</style>
@@ -105,7 +105,7 @@ const renderedHtml = computed(() => md.render(props.content))
}
th {
background: rgba($accent-primary, 0.08);
background: rgba(var(--accent-primary-rgb), 0.08);
color: $text-primary;
font-weight: 600;
}
@@ -189,4 +189,20 @@ const renderedHtml = computed(() => md.render(props.content))
.hljs-title\.function_ { color: #1a1a1a; }
.hljs-params { color: #2a2a2a; }
.hljs-meta { color: #999999; }
// Dark mode highlight.js — inverted pure ink
.dark .hljs { color: #d0d0d0; }
.dark .hljs-keyword,
.dark .hljs-selector-tag { color: #f0f0f0; font-weight: 600; }
.dark .hljs-string,
.dark .hljs-attr { color: #aaaaaa; }
.dark .hljs-number { color: #cccccc; }
.dark .hljs-comment { color: #666666; font-style: italic; }
.dark .hljs-built_in { color: #bbbbbb; }
.dark .hljs-type { color: #c6c6c6; }
.dark .hljs-variable { color: #f0f0f0; }
.dark .hljs-title,
.dark .hljs-title\.function_ { color: #f0f0f0; }
.dark .hljs-params { color: #d0d0d0; }
.dark .hljs-meta { color: #666666; }
</style>
@@ -201,7 +201,7 @@ const formattedToolResult = computed(() => {
.message-bubble {
background-color: $msg-user-bg;
border-radius: $radius-md $radius-md 4px $radius-md;
border-radius: 10px;
}
}
@@ -223,7 +223,7 @@ const formattedToolResult = computed(() => {
.message-bubble {
background-color: $msg-assistant-bg;
border-radius: $radius-md $radius-md $radius-md 4px;
border-radius: 10px;
}
}
@@ -238,7 +238,7 @@ const formattedToolResult = computed(() => {
border-left: 3px solid $warning;
border-radius: $radius-sm;
max-width: 80%;
background-color: rgba($warning, 0.06);
background-color: rgba(var(--warning-rgb), 0.06);
}
}
}
@@ -261,6 +261,7 @@ const formattedToolResult = computed(() => {
font-size: 14px;
line-height: 1.65;
word-break: break-word;
border-radius: 10px;
}
.msg-attachments {
@@ -315,6 +316,10 @@ const formattedToolResult = computed(() => {
color: $text-muted;
margin-top: 4px;
padding: 0 4px;
.dark & {
color: #999999;
}
}
.tool-line {
@@ -369,7 +374,7 @@ const formattedToolResult = computed(() => {
.tool-error-badge {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
background: rgba(var(--error-rgb), 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
@@ -91,7 +91,11 @@ watch(currentToolCalls, scrollToBottom)
display: flex;
flex-direction: column;
gap: 16px;
background-color: #ffffff;
background-color: $bg-card;
.dark & {
background-color: #333333;
}
}
.empty-state {
@@ -152,7 +152,7 @@ async function handleDelete() {
transition: border-color $transition-fast;
&:hover {
border-color: rgba($accent-primary, 0.3);
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
@@ -180,22 +180,22 @@ async function handleDelete() {
font-weight: 500;
&.success {
background: rgba($success, 0.12);
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.info {
background: rgba($accent-primary, 0.12);
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.warning {
background: rgba($warning, 0.12);
background: rgba(var(--warning-rgb), 0.12);
color: $warning;
}
&.error {
background: rgba($error, 0.12);
background: rgba(var(--error-rgb), 0.12);
color: $error;
}
}
@@ -70,7 +70,7 @@ async function handleDelete() {
transition: border-color $transition-fast;
&:hover {
border-color: rgba($accent-primary, 0.3);
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
}
@@ -98,12 +98,12 @@ async function handleDelete() {
font-weight: 500;
&.builtin {
background: rgba($accent-primary, 0.12);
background: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
&.custom {
background: rgba($success, 0.12);
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
}
@@ -181,11 +181,11 @@ async function handleExport() {
transition: border-color $transition-fast;
&:hover {
border-color: rgba($accent-primary, 0.3);
border-color: rgba(var(--accent-primary-rgb), 0.3);
}
&.active {
border-color: rgba($success, 0.4);
border-color: rgba(var(--success-rgb), 0.4);
}
}
@@ -1,12 +1,20 @@
<script setup lang="ts">
import { NSwitch, useMessage } from 'naive-ui'
import { NSwitch, NSelect, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { useTheme, type ThemeMode } from '@/composables/useTheme'
import SettingRow from './SettingRow.vue'
const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
const { mode, setMode } = useTheme()
const themeOptions = [
{ label: t('settings.display.themeLight'), value: 'light' },
{ label: t('settings.display.themeDark'), value: 'dark' },
{ label: t('settings.display.themeSystem'), value: 'system' },
]
async function save(values: Record<string, any>) {
try {
@@ -16,10 +24,19 @@ async function save(values: Record<string, any>) {
message.error(t('settings.saveFailed'))
}
}
function handleThemeChange(val: string) {
const m = val as ThemeMode
setMode(m)
save({ skin: m })
}
</script>
<template>
<section class="settings-section">
<SettingRow :label="t('settings.display.theme')" :hint="t('settings.display.themeHint')">
<NSelect :value="mode" :options="themeOptions" size="small" :consistent-menu-width="false" class="input-sm" @update:value="handleThemeChange" />
</SettingRow>
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
</SettingRow>
@@ -57,7 +57,7 @@ const configured = computed(() => {
overflow: hidden;
&.configured {
border-color: rgba($success, 0.2);
border-color: rgba(var(--success-rgb), 0.2);
}
}
@@ -70,7 +70,7 @@ const configured = computed(() => {
user-select: none;
&:hover {
background-color: rgba($text-primary, 0.03);
background-color: rgba(var(--text-primary-rgb), 0.03);
}
}
@@ -185,7 +185,7 @@ watch(() => `${props.category}/${props.skill}`, loadSkill, { immediate: true })
border-radius: 4px;
&:hover {
background: rgba($accent-primary, 0.06);
background: rgba(var(--accent-primary-rgb), 0.06);
}
}
@@ -150,7 +150,7 @@ async function handleToggle(category: string, skillName: string, newEnabled: boo
border-radius: $radius-sm;
&:hover {
background: rgba($accent-primary, 0.04);
background: rgba(var(--accent-primary-rgb), 0.04);
}
}
@@ -174,7 +174,7 @@ async function handleToggle(category: string, skillName: string, newEnabled: boo
.category-count {
font-size: 11px;
color: $text-muted;
background: rgba($accent-primary, 0.06);
background: rgba(var(--accent-primary-rgb), 0.06);
padding: 1px 6px;
border-radius: 8px;
}
@@ -200,12 +200,12 @@ async function handleToggle(category: string, skillName: string, newEnabled: boo
gap: 8px;
&:hover {
background: rgba($accent-primary, 0.06);
background: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&.active {
background: rgba($accent-primary, 0.1);
background: rgba(var(--accent-primary-rgb), 0.1);
color: $text-primary;
font-weight: 500;
}
@@ -127,6 +127,10 @@ import { computed } from 'vue'
border-radius: 2px 2px 0 0;
min-height: 0;
transition: height 0.3s ease;
.dark & {
background: #66bb6a;
}
}
.bar-col {
@@ -140,7 +144,7 @@ import { computed } from 'vue'
left: 50%;
transform: translateX(-50%);
background: $text-primary;
color: #fff;
color: var(--text-on-accent);
padding: 6px 10px;
border-radius: $radius-sm;
font-size: 11px;
@@ -85,6 +85,10 @@ function formatTokens(n: number): string {
border-radius: 3px;
min-width: 2px;
transition: width 0.3s ease;
.dark & {
background: #66bb6a;
}
}
.model-tokens {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from "vue";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useMessage } from "naive-ui";
@@ -7,62 +7,20 @@ import { useAppStore } from "@/stores/hermes/app";
import ModelSelector from "./ModelSelector.vue";
import ProfileSelector from "./ProfileSelector.vue";
import LanguageSwitch from "./LanguageSwitch.vue";
import danceVideo from "@/assets/dance.mp4";
import ThemeSwitch from "./ThemeSwitch.vue";
import danceVideoLight from "@/assets/dance-light.mp4";
import danceVideoDark from "@/assets/dance-dark.mp4";
import { useTheme } from "@/composables/useTheme";
const { t } = useI18n();
const { isDark } = useTheme();
const message = useMessage();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const canvasRef = ref<HTMLCanvasElement>();
const selectedKey = computed(() => route.name as string);
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const video = document.createElement("video");
video.src = danceVideo;
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.addEventListener("loadeddata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
function draw() {
if (video.readyState >= 2 && ctx && canvas) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
if (video.currentTime >= video.duration - 0.05) {
video.currentTime = 0;
}
requestAnimationFrame(draw);
}
video.addEventListener("canplay", () => {
draw();
});
video.play();
const onVisible = () => {
if (document.visibilityState === "visible" && video.paused) {
video.play();
}
};
document.addEventListener("visibilitychange", onVisible);
onUnmounted(() => {
document.removeEventListener("visibilitychange", onVisible);
});
});
function handleNav(key: string) {
router.push({ name: key });
}
@@ -82,7 +40,7 @@ async function handleUpdate() {
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<canvas ref="canvasRef" class="logo-dance" />
<video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline />
</div>
<nav class="sidebar-nav">
@@ -360,6 +318,7 @@ async function handleUpdate() {
</div>
<div class="version-info">
<span>Hermes Web UI v{{ appStore.serverVersion || "0.1.0" }}</span>
<ThemeSwitch />
<a v-if="appStore.updateAvailable" class="update-hint" :class="{ loading: appStore.updating }" @click="handleUpdate">
{{ appStore.updating ? t('sidebar.updating') : t('sidebar.updateVersion', { version: appStore.latestVersion }) }}
</a>
@@ -398,8 +357,12 @@ async function handleUpdate() {
margin: 0 -12px;
color: $text-primary;
cursor: pointer;
background-color: #ffffff;
background-color: $bg-card;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
.dark & {
background-color: #333333;
}
position: relative;
overflow: hidden;
@@ -419,6 +382,7 @@ async function handleUpdate() {
object-fit: contain;
flex-shrink: 0;
width: auto;
pointer-events: none;
}
}
@@ -453,12 +417,12 @@ async function handleUpdate() {
text-align: left;
&:hover {
background-color: rgba($accent-primary, 0.06);
background-color: rgba(var(--accent-primary-rgb), 0.06);
color: $text-primary;
}
&.active {
background-color: rgba($accent-primary, 0.12);
background-color: rgba(var(--accent-primary-rgb), 0.12);
color: $accent-primary;
}
}
@@ -490,7 +454,7 @@ async function handleUpdate() {
&.connected .status-dot {
background-color: $success;
box-shadow: 0 0 6px rgba($success, 0.5);
box-shadow: 0 0 6px rgba(var(--success-rgb), 0.5);
}
&.disconnected .status-dot {
@@ -507,8 +471,9 @@ async function handleUpdate() {
font-size: 11px;
color: $text-muted;
display: flex;
flex-direction: column;
gap: 2px;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.update-hint {
@@ -516,15 +481,15 @@ async function handleUpdate() {
margin-top: 4px;
padding: 5px 10px;
border-radius: $radius-sm;
background: #333333;
color: rgba(#fff, 0.7);
background: var(--accent-primary);
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
text-align: center;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: #3d3d3d;
background: var(--accent-hover);
}
&.loading {
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
const { isDark, toggleTheme } = useTheme()
</script>
<template>
<button class="theme-switch" :title="isDark ? 'Light mode' : 'Dark mode'" @click="toggleTheme">
<!-- Sun icon (shown in dark mode) -->
<svg v-if="isDark" width="16" height="16" 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" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<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) -->
<svg v-else width="16" height="16" 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>
</template>
<style scoped lang="scss">
.theme-switch {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--text-primary);
background: rgba(var(--accent-primary-rgb), 0.06);
}
}
</style>