feat: add comic/doodle theme style (#603)
* feat: add comic/doodle theme style with local font Add a new "comic" theme style that applies hand-drawn aesthetics (Comic Neue font, bold borders, heavy font weight) while keeping the original light/dark background colors. Font files are bundled locally to avoid Google Fonts CDN dependency. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: update DisplaySettings to use renamed theme API and update brand assets Rename mode/setMode/ThemeMode to brightness/setBrightness/BrightnessMode to match the refactored useTheme composable. Update favicon and logo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.8 MiB |
@@ -10,14 +10,14 @@ import { useKeyboard } from '@/composables/useKeyboard'
|
|||||||
import { useAppStore } from '@/stores/hermes/app'
|
import { useAppStore } from '@/stores/hermes/app'
|
||||||
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark, isComic } = useTheme()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
|
|
||||||
const themeOverrides = computed(() => getThemeOverrides(isDark.value))
|
const themeOverrides = computed(() => getThemeOverrides(isDark.value, isComic.value))
|
||||||
const naiveTheme = computed(() => isDark.value ? darkTheme : null)
|
const naiveTheme = computed(() => isDark.value ? darkTheme : null)
|
||||||
|
|
||||||
const isLoginPage = computed(() => route.name === 'login')
|
const isLoginPage = computed(() => route.name === 'login')
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ async function handleRefresh() {
|
|||||||
|
|
||||||
.file-toolbar {
|
.file-toolbar {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { NSwitch, NSelect, useMessage } from 'naive-ui'
|
import { NSwitch, NSelect, useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSettingsStore } from '@/stores/hermes/settings'
|
import { useSettingsStore } from '@/stores/hermes/settings'
|
||||||
import { useTheme, type ThemeMode } from '@/composables/useTheme'
|
import { useTheme, type BrightnessMode } from '@/composables/useTheme'
|
||||||
import SettingRow from './SettingRow.vue'
|
import SettingRow from './SettingRow.vue'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { mode, setMode } = useTheme()
|
const { brightness, setBrightness } = useTheme()
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ label: t('settings.display.themeLight'), value: 'light' },
|
{ label: t('settings.display.themeLight'), value: 'light' },
|
||||||
@@ -26,8 +26,8 @@ async function save(values: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleThemeChange(val: string) {
|
function handleThemeChange(val: string) {
|
||||||
const m = val as ThemeMode
|
const m = val as BrightnessMode
|
||||||
setMode(m)
|
setBrightness(m)
|
||||||
save({ skin: m })
|
save({ skin: m })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -35,7 +35,7 @@ function handleThemeChange(val: string) {
|
|||||||
<template>
|
<template>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<SettingRow :label="t('settings.display.theme')" :hint="t('settings.display.themeHint')">
|
<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" />
|
<NSelect :value="brightness" :options="themeOptions" size="small" :consistent-menu-width="false" class="input-sm" @update:value="handleThemeChange" />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
|
<SettingRow :label="t('settings.display.streaming')" :hint="t('settings.display.streamingHint')">
|
||||||
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
|
<NSwitch :value="settingsStore.display.streaming" @update:value="v => save({ streaming: v })" />
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, isComic, toggleBrightness, toggleStyle } = useTheme()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="theme-switch" :title="isDark ? 'Light mode' : 'Dark mode'" @click="toggleTheme">
|
<div style="display: flex; gap: 4px; align-items: center;">
|
||||||
|
<button class="theme-switch" :title="isComic ? 'Ink style' : 'Comic style'" @click="toggleStyle">
|
||||||
|
<!-- Palette icon for comic toggle -->
|
||||||
|
<svg v-if="isComic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
|
||||||
|
</svg>
|
||||||
|
<!-- Sparkle icon for ink 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="M12 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5L12 3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="theme-switch" :title="isDark ? 'Light mode' : 'Dark mode'" @click="toggleBrightness">
|
||||||
<!-- Sun icon (shown in dark mode) -->
|
<!-- 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">
|
<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" />
|
<circle cx="12" cy="12" r="5" />
|
||||||
@@ -23,6 +34,7 @@ const { isDark, toggleTheme } = useTheme()
|
|||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -1,57 +1,89 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
export type BrightnessMode = 'light' | 'dark' | 'system'
|
||||||
|
export type ThemeStyle = 'ink' | 'comic'
|
||||||
|
|
||||||
const STORAGE_KEY = 'hermes_theme'
|
const BRIGHTNESS_KEY = 'hermes_brightness'
|
||||||
|
const STYLE_KEY = 'hermes_style'
|
||||||
|
|
||||||
const mode = ref<ThemeMode>(
|
const brightness = ref<BrightnessMode>(
|
||||||
(localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
|
(localStorage.getItem(BRIGHTNESS_KEY) as BrightnessMode) || 'system',
|
||||||
|
)
|
||||||
|
|
||||||
|
const style = ref<ThemeStyle>(
|
||||||
|
(localStorage.getItem(STYLE_KEY) as ThemeStyle) || 'ink',
|
||||||
)
|
)
|
||||||
|
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
const isComic = ref(false)
|
||||||
|
|
||||||
function applyTheme(dark: boolean) {
|
function resolveDark(b: BrightnessMode): boolean {
|
||||||
isDark.value = dark
|
if (b === 'system') {
|
||||||
document.documentElement.classList.toggle('dark', dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDark(m: ThemeMode): boolean {
|
|
||||||
if (m === 'system') {
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
}
|
}
|
||||||
return m === 'dark'
|
return b === 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial resolve
|
function applyClasses() {
|
||||||
applyTheme(resolveDark(mode.value))
|
const dark = resolveDark(brightness.value)
|
||||||
|
isDark.value = dark
|
||||||
|
isComic.value = style.value === 'comic'
|
||||||
|
document.documentElement.classList.toggle('dark', dark)
|
||||||
|
document.documentElement.classList.toggle('comic', isComic.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial
|
||||||
|
applyClasses()
|
||||||
|
|
||||||
// Listen for system preference changes
|
// Listen for system preference changes
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
mediaQuery.addEventListener('change', () => {
|
if (brightness.value === 'system') {
|
||||||
if (mode.value === 'system') {
|
applyClasses()
|
||||||
applyTheme(resolveDark('system'))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch mode changes
|
// Persist & apply on change
|
||||||
watch(mode, (newMode) => {
|
watch(brightness, (b) => {
|
||||||
localStorage.setItem(STORAGE_KEY, newMode)
|
localStorage.setItem(BRIGHTNESS_KEY, b)
|
||||||
applyTheme(resolveDark(newMode))
|
applyClasses()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(style, (s) => {
|
||||||
|
localStorage.setItem(STYLE_KEY, s)
|
||||||
|
applyClasses()
|
||||||
})
|
})
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
function setMode(m: ThemeMode) {
|
const themeName = computed(() => {
|
||||||
mode.value = m
|
const b = isDark.value ? 'dark' : 'light'
|
||||||
|
return isComic.value ? `comic-${b}` : b
|
||||||
|
})
|
||||||
|
|
||||||
|
function setBrightness(b: BrightnessMode) {
|
||||||
|
brightness.value = b
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function setStyle(s: ThemeStyle) {
|
||||||
mode.value = isDark.value ? 'light' : 'dark'
|
style.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBrightness() {
|
||||||
|
brightness.value = isDark.value ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStyle() {
|
||||||
|
style.value = isComic.value ? 'ink' : 'comic'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode,
|
brightness,
|
||||||
|
style,
|
||||||
isDark,
|
isDark,
|
||||||
setMode,
|
isComic,
|
||||||
toggleTheme,
|
themeName,
|
||||||
|
setBrightness,
|
||||||
|
setStyle,
|
||||||
|
toggleBrightness,
|
||||||
|
toggleStyle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
@use 'variables' as *;
|
@use 'variables' as *;
|
||||||
@use 'code-block';
|
@use 'code-block';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Comic Neue';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/ComicNeue-Regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Comic Neue';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/ComicNeue-Bold.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -9,6 +25,13 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--vh: 1vh;
|
--vh: 1vh;
|
||||||
}
|
}
|
||||||
@@ -45,6 +68,21 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.comic body {
|
||||||
|
font-weight: var(--comic-font-weight, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.comic button,
|
||||||
|
html.comic input,
|
||||||
|
html.comic select,
|
||||||
|
html.comic textarea,
|
||||||
|
html.comic a,
|
||||||
|
html.comic span,
|
||||||
|
html.comic div,
|
||||||
|
html.comic label {
|
||||||
|
font-weight: var(--comic-font-weight, 700);
|
||||||
|
}
|
||||||
|
|
||||||
code, pre, .mono {
|
code, pre, .mono {
|
||||||
font-family: $font-code;
|
font-family: $font-code;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,15 @@ export const darkThemeOverrides: GlobalThemeOverrides = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThemeOverrides(isDark: boolean): GlobalThemeOverrides {
|
export function getThemeOverrides(isDark: boolean, isComic?: boolean): GlobalThemeOverrides {
|
||||||
return isDark ? darkThemeOverrides : lightThemeOverrides
|
const base = isDark ? darkThemeOverrides : lightThemeOverrides
|
||||||
|
if (!isComic) return base
|
||||||
|
const comicFont = "'Comic Neue', 'Comic Sans MS', cursive, sans-serif"
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
common: {
|
||||||
|
...base.common!,
|
||||||
|
fontFamily: comicFont,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// 黑白水墨 — Pure Ink
|
// 黑白水墨 — Pure Ink
|
||||||
// 纯黑白灰,无彩色
|
// 纯黑白灰,无彩色
|
||||||
// 支持 light / dark 双主题
|
// 支持 light / dark / comic 三主题
|
||||||
|
|
||||||
// ─── CSS Custom Properties ─────────────────────────────────────
|
// ─── CSS Custom Properties ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,6 +108,28 @@
|
|||||||
--accent-info-rgb: 107, 163, 214;
|
--accent-info-rgb: 107, 163, 214;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comic {
|
||||||
|
// Borders — bold comic outlines
|
||||||
|
--border-color: #1a1a1a;
|
||||||
|
--border-light: #555555;
|
||||||
|
--msg-system-border: #1a1a1a;
|
||||||
|
|
||||||
|
// Comic-specific
|
||||||
|
--font-ui: 'Comic Neue', 'Comic Sans MS', cursive, sans-serif;
|
||||||
|
--comic-border-width: 2.5px;
|
||||||
|
--comic-shadow: 3px 3px 0px rgba(0, 0, 0, 0.15);
|
||||||
|
--comic-font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.comic {
|
||||||
|
// Borders — brighter outlines on dark
|
||||||
|
--border-color: #666666;
|
||||||
|
--border-light: #555555;
|
||||||
|
--msg-system-border: #888888;
|
||||||
|
|
||||||
|
--comic-shadow: 3px 3px 0px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── SCSS Variables (delegate to CSS custom properties) ────────
|
// ─── SCSS Variables (delegate to CSS custom properties) ────────
|
||||||
|
|
||||||
// Backgrounds
|
// Backgrounds
|
||||||
@@ -147,7 +169,7 @@ $msg-system-border: var(--msg-system-border);
|
|||||||
$code-bg: var(--code-bg);
|
$code-bg: var(--code-bg);
|
||||||
|
|
||||||
// Typography
|
// Typography
|
||||||
$font-ui: 'Inter', system-ui, -apple-system, sans-serif;
|
$font-ui: var(--font-ui, 'Inter', system-ui, -apple-system, sans-serif);
|
||||||
$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.8 MiB |