diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 019284713..c769477a9 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -207,6 +207,13 @@ export default defineConfig({ build: { // Shut the fuck up chunkSizeWarningLimit: Number.POSITIVE_INFINITY + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler' + } + } } }, markdown: { diff --git a/docs/.vitepress/theme/components/ColorPicker.vue b/docs/.vitepress/theme/components/ColorPicker.vue index 8cf98c059..fb306f7b9 100644 --- a/docs/.vitepress/theme/components/ColorPicker.vue +++ b/docs/.vitepress/theme/components/ColorPicker.vue @@ -1,24 +1,37 @@ diff --git a/docs/.vitepress/theme/components/CustomColorSelector.vue b/docs/.vitepress/theme/components/CustomColorSelector.vue new file mode 100644 index 000000000..e08890637 --- /dev/null +++ b/docs/.vitepress/theme/components/CustomColorSelector.vue @@ -0,0 +1,486 @@ + + + + + diff --git a/docs/.vitepress/theme/components/ThemeDropdown.vue b/docs/.vitepress/theme/components/ThemeDropdown.vue index e7f2d6994..e13b88ab4 100644 --- a/docs/.vitepress/theme/components/ThemeDropdown.vue +++ b/docs/.vitepress/theme/components/ThemeDropdown.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useTheme } from '../themes/themeHandler' import type { DisplayMode } from '../themes/types' -const { mode, setMode, state, amoledEnabled, setAmoledEnabled } = useTheme() +const { mode, setMode, amoledEnabled, setAmoledEnabled } = useTheme() const isOpen = ref(false) const dropdownRef = ref(null) @@ -15,18 +15,33 @@ interface ModeChoice { isAmoled?: boolean } -const modeChoices: ModeChoice[] = [ +const baseModeChoices: ModeChoice[] = [ { mode: 'light', label: 'Light', icon: 'i-ph-sun-duotone' }, { mode: 'dark', label: 'Dark', icon: 'i-ph-moon-duotone' }, - { mode: 'dark', label: 'AMOLED', icon: 'i-ph-moon-stars-duotone', isAmoled: true } + { mode: 'dark', label: 'AMOLED', icon: 'i-ph-moon-stars-duotone', isAmoled: true }, + { mode: 'custom', label: 'Custom', icon: 'i-lucide-palette' } ] +// Only show custom mode when already in custom mode +const modeChoices = computed(() => { + const current = (mode && (mode as any).value) ? (mode as any).value : 'light' + if (current === 'custom') { + return baseModeChoices + } + // Filter out custom mode when not in custom + return baseModeChoices.filter(choice => choice.mode !== 'custom') +}) + const currentChoice = computed(() => { const current = (mode && (mode as any).value) ? (mode as any).value : 'light' - if (current === 'dark' && amoledEnabled.value) { - return modeChoices[2] // AMOLED option + // Handle custom mode + if (current === 'custom') { + return baseModeChoices[3] // Custom option } - return modeChoices.find(choice => choice.mode === current && !choice.isAmoled) || modeChoices[0] + if (current === 'dark' && amoledEnabled.value) { + return baseModeChoices[2] // AMOLED option + } + return baseModeChoices.find(choice => choice.mode === current && !choice.isAmoled) || baseModeChoices[0] }) const toggleDropdown = () => { @@ -34,6 +49,20 @@ const toggleDropdown = () => { } const selectMode = (choice: ModeChoice) => { + const current = (mode && (mode as any).value) ? (mode as any).value : 'light' + + // Prevent switching to Light/Dark/AMOLED when in custom mode + if (current === 'custom' && choice.mode !== 'custom') { + isOpen.value = false + return + } + + // Prevent switching when clicking custom (clicking custom does nothing) + if (choice.mode === 'custom') { + isOpen.value = false + return + } + if (choice.isAmoled) { setMode('dark') setAmoledEnabled(true) @@ -46,12 +75,31 @@ const selectMode = (choice: ModeChoice) => { const isActiveChoice = (choice: ModeChoice) => { const current = (mode && (mode as any).value) ? (mode as any).value : 'light' + // Handle custom mode + if (choice.mode === 'custom') { + return current === 'custom' + } if (choice.isAmoled) { return current === 'dark' && amoledEnabled.value } return choice.mode === current && !choice.isAmoled && !amoledEnabled.value } +// Check if a choice should be disabled +const isDisabled = (choice: ModeChoice) => { + const current = (mode && (mode as any).value) ? (mode as any).value : 'light' + // Disable Light/Dark/AMOLED when in custom mode + return current === 'custom' && choice.mode !== 'custom' +} + +// Get tooltip for disabled items +const getTooltip = (choice: ModeChoice) => { + if (isDisabled(choice)) { + return 'Use default themes to select this' + } + return choice.label +} + const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) { isOpen.value = false @@ -86,7 +134,8 @@ onUnmounted(() => { v-for="(choice, index) in modeChoices" :key="index" class="theme-dropdown-item" - :class="{ active: isActiveChoice(choice) }" + :class="{ active: isActiveChoice(choice), disabled: isDisabled(choice) }" + :title="getTooltip(choice)" @click="selectMode(choice)" >
@@ -166,6 +215,16 @@ onUnmounted(() => { font-weight: 500; } + &.disabled { + color: var(--vp-c-text-3); + opacity: 0.5; + cursor: not-allowed; + + &:hover { + background: transparent; + } + } + span { flex: 1; } diff --git a/docs/.vitepress/theme/themes/themeHandler.ts b/docs/.vitepress/theme/themes/themeHandler.ts index c11c1601d..bb175abbf 100644 --- a/docs/.vitepress/theme/themes/themeHandler.ts +++ b/docs/.vitepress/theme/themes/themeHandler.ts @@ -17,10 +17,12 @@ import { ref, onMounted, computed } from 'vue' import type { DisplayMode, ThemeState, Theme, ModeColors } from './types' import { themeRegistry } from './configs' +import tinycolor from 'tinycolor2' const STORAGE_KEY_THEME = 'vitepress-theme-name' const STORAGE_KEY_MODE = 'vitepress-display-mode' const STORAGE_KEY_AMOLED = 'vitepress-amoled-enabled' +const STORAGE_KEY_PREVIOUS_MODE = 'vitepress-previous-mode' export class ThemeHandler { private state = ref({ @@ -29,11 +31,178 @@ export class ThemeHandler { theme: null }) private amoledEnabled = ref(false) + private previousMode = ref('light') constructor() { this.initializeTheme() } + private registerCustomThemeFromStorage() { + if (typeof window === 'undefined') return + + // Load saved custom colors from localStorage + const savedLinkColor = localStorage.getItem('custom-theme-link-color') || '#ffffff' + const savedTextColor = localStorage.getItem('custom-theme-text-color') || '#cccccc' + const savedBgColor = localStorage.getItem('custom-theme-bg-color') || '#000000' + + // Create lighter versions of background for cards + // Increase lightening to make cards more distinct + const lightenedBg = tinycolor(savedBgColor).lighten(10).toString() + const lightenedBgAlt = tinycolor(savedBgColor).lighten(15).toString() + + // Create custom theme with saved colors + const customTheme = { + name: 'custom', + displayName: 'Custom', + preview: savedBgColor, + modes: { + light: { + brand: { + 1: savedLinkColor, + 2: savedLinkColor, + 3: savedLinkColor, + soft: savedLinkColor + }, + bg: savedBgColor, + bgAlt: lightenedBg, + bgElv: lightenedBgAlt, + text: { + 1: savedTextColor, + 2: savedTextColor, + 3: savedTextColor + }, + button: { + brand: { + bg: savedLinkColor, + border: savedLinkColor, + text: savedBgColor, + hoverBorder: savedLinkColor, + hoverText: savedBgColor, + hoverBg: savedLinkColor, + activeBorder: savedLinkColor, + activeText: savedBgColor, + activeBg: savedLinkColor + }, + alt: { + bg: '#484848', + text: '#f0eeee', + hoverBg: '#484848', + hoverText: '#f0eeee' + } + }, + customBlock: { + info: { + bg: savedBgColor, + border: savedLinkColor, + text: savedTextColor, + textDeep: savedTextColor + }, + tip: { + bg: '#D8F8E4', + border: '#447A61', + text: '#2D6A58', + textDeep: '#166534' + }, + warning: { + bg: '#FCEFC3', + border: '#9A8034', + text: '#9C701B', + textDeep: '#92400e' + }, + danger: { + bg: '#FBE1E2', + border: '#B3565E', + text: '#912239', + textDeep: '#991b1b' + } + }, + selection: { + bg: savedLinkColor + }, + home: { + heroNameColor: savedLinkColor, + heroNameBackground: savedBgColor, + heroImageBackground: `linear-gradient(135deg, ${savedBgColor} 0%, ${savedLinkColor} 100%)`, + heroImageFilter: 'blur(44px)' + } + }, + dark: { + brand: { + 1: savedLinkColor, + 2: savedLinkColor, + 3: savedLinkColor, + soft: savedLinkColor + }, + bg: savedBgColor, + bgAlt: lightenedBg, + bgElv: lightenedBgAlt, + text: { + 1: savedTextColor, + 2: savedTextColor, + 3: savedTextColor + }, + button: { + brand: { + bg: savedLinkColor, + border: savedLinkColor, + text: savedBgColor, + hoverBorder: savedLinkColor, + hoverText: savedBgColor, + hoverBg: savedLinkColor, + activeBorder: savedLinkColor, + activeText: savedBgColor, + activeBg: savedLinkColor + }, + alt: { + bg: '#484848', + text: '#f0eeee', + hoverBg: '#484848', + hoverText: '#f0eeee' + } + }, + customBlock: { + info: { + bg: savedBgColor, + border: savedLinkColor, + text: savedTextColor, + textDeep: savedTextColor + }, + tip: { + bg: '#0C2A20', + border: '#184633', + text: '#B0EBC9', + textDeep: '#166534' + }, + warning: { + bg: '#403207', + border: '#7E6211', + text: '#F9DE88', + textDeep: '#92400e' + }, + danger: { + bg: '#3F060A', + border: '#7C0F18', + text: '#F7C1BC', + textDeep: '#991b1b' + } + }, + selection: { + bg: savedLinkColor + }, + home: { + heroNameColor: savedLinkColor, + heroNameBackground: savedBgColor, + heroImageBackground: `linear-gradient(135deg, ${savedBgColor} 0%, ${savedLinkColor} 100%)`, + heroImageFilter: 'blur(44px)' + } + } + } + } + + // Register custom theme + themeRegistry['custom'] = customTheme + } + private initializeTheme() { if (typeof window === 'undefined') return @@ -42,6 +211,11 @@ export class ThemeHandler { const savedMode = localStorage.getItem(STORAGE_KEY_MODE) as DisplayMode | null const savedAmoled = localStorage.getItem(STORAGE_KEY_AMOLED) === 'true' + // If custom theme was saved, register it early from localStorage + if (savedTheme === 'custom') { + this.registerCustomThemeFromStorage() + } + if (themeRegistry[savedTheme]) { this.state.value.currentTheme = savedTheme this.state.value.theme = themeRegistry[savedTheme] @@ -91,7 +265,9 @@ export class ThemeHandler { if (!theme) return - const modeColors = theme.modes[currentMode] + // Custom mode uses dark mode colors from the theme + const effectiveMode = currentMode === 'custom' ? 'dark' : currentMode + const modeColors = theme.modes[effectiveMode] this.applyDOMClasses(currentMode) this.applyCSSVariables(modeColors, theme) @@ -107,7 +283,7 @@ export class ThemeHandler { const root = document.documentElement // Remove all mode classes - root.classList.remove('dark', 'light', 'amoled') + root.classList.remove('dark', 'light', 'amoled', 'custom') // Add current mode class root.classList.add(mode) @@ -159,6 +335,14 @@ export class ThemeHandler { root.style.setProperty('--vp-c-bg', bgColor) root.style.setProperty('--vp-c-bg-alt', bgAltColor) root.style.setProperty('--vp-c-bg-elv', bgElvColor) + + // Apply additional background variables for cards and other elements + root.style.setProperty('--vp-c-bg-soft', bgAltColor) + root.style.setProperty('--vp-c-default-soft', bgElvColor) + root.style.setProperty('--vp-c-default-1', bgAltColor) + root.style.setProperty('--vp-c-default-2', bgElvColor) + root.style.setProperty('--vp-c-default-3', bgColor) + if (colors.bgMark) { root.style.setProperty('--vp-c-bg-mark', colors.bgMark) } @@ -282,6 +466,12 @@ export class ThemeHandler { } public setMode(mode: DisplayMode) { + // Save current mode as previous mode before switching to custom + if (mode === 'custom' && this.state.value.currentMode !== 'custom') { + this.previousMode.value = this.state.value.currentMode + localStorage.setItem(STORAGE_KEY_PREVIOUS_MODE, this.previousMode.value) + } + this.state.value.currentMode = mode localStorage.setItem(STORAGE_KEY_MODE, mode) this.applyTheme() @@ -319,7 +509,9 @@ export class ThemeHandler { if (!theme) return // If theme doesn't specify brand colors, force ColorPicker to reapply its selection const currentMode = this.state.value.currentMode - const modeColors = theme.modes[currentMode] + // Custom mode uses dark mode colors + const effectiveMode = currentMode === 'custom' ? 'dark' : currentMode + const modeColors = theme.modes[effectiveMode] if (!modeColors.brand || !modeColors.brand[1]) { // Trigger a custom event that ColorPicker can listen to @@ -360,6 +552,15 @@ export class ThemeHandler { public isAmoledMode() { return this.state.value.currentMode === 'dark' && this.amoledEnabled.value } + + public restorePreviousMode() { + // Only restore if currently in custom mode + if (this.state.value.currentMode === 'custom') { + const savedPreviousMode = localStorage.getItem(STORAGE_KEY_PREVIOUS_MODE) as DisplayMode | null + const modeToRestore = savedPreviousMode || this.previousMode.value + this.setMode(modeToRestore) + } + } } // Global theme handler instance @@ -395,6 +596,7 @@ export function useTheme() { amoledEnabled: handler.getAmoledEnabledRef(), setAmoledEnabled: (enabled: boolean) => handler.setAmoledEnabled(enabled), toggleAmoled: () => handler.toggleAmoled(), + restorePreviousMode: () => handler.restorePreviousMode(), state } } \ No newline at end of file diff --git a/docs/.vitepress/theme/themes/types.ts b/docs/.vitepress/theme/themes/types.ts index de54fa80a..56e3d2bf2 100644 --- a/docs/.vitepress/theme/themes/types.ts +++ b/docs/.vitepress/theme/themes/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export type DisplayMode = 'light' | 'dark' +export type DisplayMode = 'light' | 'dark' | 'custom' export interface ModeColors { // Brand colors (optional - if not specified, ColorPicker values are used) diff --git a/docs/.vitepress/transformer/constants.ts b/docs/.vitepress/transformer/constants.ts index 727a3144b..91765cf0a 100644 --- a/docs/.vitepress/transformer/constants.ts +++ b/docs/.vitepress/transformer/constants.ts @@ -147,7 +147,7 @@ export function getHeader(id: string) { const title = '

' - const description = '

' + const description = '

' const feedback = meta.build.api ? '' : '' diff --git a/package-lock.json b/package-lock.json index bc3a32bdf..56915ee32 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 0a62134a3..414e9aee2 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,9 @@ "nprogress": "^0.2.0", "pathe": "^2.0.3", "reka-ui": "^2.6.1", + "tinycolor2": "^1.6.0", "unocss": "66.5.10", "vitepress": "^1.6.4", - "vue": "^3.5.25", "x-satori": "^0.4.0", "zod": "^4.1.13" }, @@ -61,6 +61,7 @@ "@iconify/utils": "^3.1.0", "@types/node": "^24.10.1", "@types/nprogress": "^0.2.3", + "@types/tinycolor2": "^1.4.6", "@vue/compiler-sfc": "^3.5.27", "floating-vue": "^5.2.2", "nitro-cloudflare-dev": "^0.2.2", @@ -73,6 +74,7 @@ "vite-plugin-optimize-exclude": "^0.0.1", "vite-plugin-pwa": "^1.2.0", "vite-plugin-terminal": "^1.3.0", + "vue": "^3.5.0", "wrangler": "^4.52.1" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 820f45b8a..5a1c5c47e 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/unocss.config.ts b/unocss.config.ts index 001b1f731..f940a4d1f 100644 --- a/unocss.config.ts +++ b/unocss.config.ts @@ -102,12 +102,12 @@ export default defineConfig({ { display: 'inline-block', padding: '0.2em 0.4em', - fontSize: '0.75em', - fontWeight: '500', - lineHeight: '1', + 'font-size': '0.75em', + 'font-weight': '500', + 'line-height': '1', color: 'var(--vp-c-text-1)', - backgroundColor: 'rgb(var(--vp-c-bg-alt))', - borderRadius: '4px' + 'background-color': 'rgb(var(--vp-c-bg-alt))', + 'border-radius': '4px' } ] ],