feat(theme): implement dynamic theme logo with reactive color updates

This commit is contained in:
catlog22
2026-02-28 23:08:27 +08:00
parent e42597b1bc
commit e83414abf3
7 changed files with 179 additions and 31 deletions

View File

@@ -3,6 +3,7 @@
// ======================================== // ========================================
// Line-style logo for Claude Code Workflow // Line-style logo for Claude Code Workflow
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface CCWLogoProps { interface CCWLogoProps {
@@ -14,11 +15,54 @@ interface CCWLogoProps {
showDot?: boolean; showDot?: boolean;
} }
/**
* Hook to get reactive theme accent color
*/
function useThemeAccentColor(): string {
const [accentColor, setAccentColor] = useState<string>(() => {
if (typeof document === 'undefined') return 'hsl(220, 60%, 65%)';
const root = document.documentElement;
const accentValue = getComputedStyle(root).getPropertyValue('--accent').trim();
return accentValue ? `hsl(${accentValue})` : 'hsl(220, 60%, 65%)';
});
useEffect(() => {
const updateAccentColor = () => {
const root = document.documentElement;
const accentValue = getComputedStyle(root).getPropertyValue('--accent').trim();
setAccentColor(accentValue ? `hsl(${accentValue})` : 'hsl(220, 60%, 65%)');
};
// Initial update
updateAccentColor();
// Watch for theme changes via MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
updateAccentColor();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
return accentColor;
}
/** /**
* Line-style CCW logo component * Line-style CCW logo component
* Features three horizontal lines with a status dot that follows theme color * Features three horizontal lines with a status dot that follows theme color
*/ */
export function CCWLogo({ size = 24, className, showDot = true }: CCWLogoProps) { export function CCWLogo({ size = 24, className, showDot = true }: CCWLogoProps) {
const accentColor = useThemeAccentColor();
return ( return (
<svg <svg
width={size} width={size}
@@ -27,7 +71,7 @@ export function CCWLogo({ size = 24, className, showDot = true }: CCWLogoProps)
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={cn('ccw-logo', className)} className={cn('ccw-logo', className)}
style={{ color: 'hsl(var(--accent))' }} style={{ color: accentColor }}
aria-label="Claude Code Workflow" aria-label="Claude Code Workflow"
> >
{/* Three horizontal lines - line style */} {/* Three horizontal lines - line style */}

View File

@@ -74,7 +74,7 @@ export function Header({
to="/" to="/"
className="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-opacity" className="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-opacity"
> >
<CCWLogo size={24} className="text-primary" /> <CCWLogo size={24} />
<span className="hidden sm:inline text-primary">{formatMessage({ id: 'navigation.header.brand' })}</span> <span className="hidden sm:inline text-primary">{formatMessage({ id: 'navigation.header.brand' })}</span>
<span className="sm:hidden text-primary">{formatMessage({ id: 'navigation.header.brandShort' })}</span> <span className="sm:hidden text-primary">{formatMessage({ id: 'navigation.header.brandShort' })}</span>
</Link> </Link>

View File

@@ -870,6 +870,16 @@
/* =========================== /* ===========================
CCW Logo CCW Logo
=========================== */ =========================== */
.ccw-logo { .ccw-logo,
color: hsl(var(--accent)); svg.ccw-logo,
header .ccw-logo {
color: hsl(var(--accent)) !important;
} }
/* Ensure dot inherits color */
.ccw-logo circle,
svg.ccw-logo circle {
fill: currentColor;
}
/* Theme-specific accent colors are applied automatically via --accent variable */

View File

@@ -509,9 +509,9 @@ onUnmounted(() => {
} }
.section-container { .section-container {
max-width: 1152px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0 2rem;
width: 100%; width: 100%;
} }
@@ -529,13 +529,13 @@ onUnmounted(() => {
} }
.hero-container { .hero-container {
max-width: 1152px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 2rem; gap: 2rem;
align-items: center; align-items: center;
padding: 0; padding: 0 2rem;
width: 100%; width: 100%;
} }
@@ -568,7 +568,7 @@ onUnmounted(() => {
} }
.hero-title { .hero-title {
font-size: 3.5rem; font-size: 2.75rem;
line-height: 1.15; line-height: 1.15;
font-weight: 800; font-weight: 800;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@@ -863,8 +863,10 @@ onUnmounted(() => {
grid-template-columns: 1fr 1.2fr; grid-template-columns: 1fr 1.2fr;
gap: 3rem; gap: 3rem;
align-items: center; align-items: center;
padding: 5rem 0; padding: 5rem 2rem;
width: 100%; width: 100%;
max-width: 1200px;
margin: 0 auto;
} }
.json-text h2 { font-size: 2.25rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--vp-c-text-1); line-height: 1.2; } .json-text h2 { font-size: 2.25rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--vp-c-text-1); line-height: 1.2; }
.json-text p { font-size: 1.05rem; color: var(--vp-c-text-2); margin-bottom: 2rem; line-height: 1.7; } .json-text p { font-size: 1.05rem; color: var(--vp-c-text-2); margin-bottom: 2rem; line-height: 1.7; }
@@ -910,6 +912,9 @@ onUnmounted(() => {
grid-template-columns: 0.85fr 1.15fr; grid-template-columns: 0.85fr 1.15fr;
gap: 3rem; gap: 3rem;
align-items: start; align-items: start;
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
} }
.quickstart-title { .quickstart-title {
@@ -1037,6 +1042,8 @@ onUnmounted(() => {
.cta-card { .cta-card {
text-align: center; text-align: center;
padding: 3.5rem 2rem; padding: 3.5rem 2rem;
max-width: 800px;
margin: 0 auto;
background: var(--vp-c-bg-soft); background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider); border: 1px solid var(--vp-c-divider);
border-radius: 24px; border-radius: 24px;

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const dotColor = ref('var(--vp-c-primary)')
function updateDotColor() {
if (typeof document === 'undefined') return
const root = document.documentElement
const style = getComputedStyle(root)
const primaryColor = style.getPropertyValue('--vp-c-primary').trim()
dotColor.value = primaryColor || 'currentColor'
}
let observer: MutationObserver | null = null
onMounted(() => {
updateDotColor()
// Watch for theme changes via MutationObserver
observer = new MutationObserver(() => {
updateDotColor()
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
})
})
onUnmounted(() => {
observer?.disconnect()
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
class="theme-logo"
aria-label="Claude Code Workflow"
>
<!-- Three horizontal lines - use currentColor to inherit from text -->
<line x1="3" y1="6" x2="18" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="3" y1="18" x2="12" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<!-- Status dot - follows theme primary color -->
<circle cx="19" cy="17" r="3" :style="{ fill: dotColor }"/>
</svg>
</template>
<style scoped>
.theme-logo {
width: 24px;
height: 24px;
color: var(--vp-c-text-1);
}
.theme-logo circle {
fill: var(--vp-c-primary);
transition: fill 0.3s ease;
}
</style>

View File

@@ -2,6 +2,7 @@
import DefaultTheme from 'vitepress/theme' import DefaultTheme from 'vitepress/theme'
import { onBeforeUnmount, onMounted } from 'vue' import { onBeforeUnmount, onMounted } from 'vue'
import { useDynamicIcon } from '../composables/useDynamicIcon' import { useDynamicIcon } from '../composables/useDynamicIcon'
import ThemeLogo from '../components/ThemeLogo.vue'
let mediaQuery: MediaQueryList | null = null let mediaQuery: MediaQueryList | null = null
let systemThemeChangeHandler: (() => void) | null = null let systemThemeChangeHandler: (() => void) | null = null
@@ -50,6 +51,11 @@ onBeforeUnmount(() => {
<template> <template>
<DefaultTheme.Layout> <DefaultTheme.Layout>
<!-- Custom logo in navbar that follows theme color -->
<template #nav-bar-title-before>
<ThemeLogo class="nav-logo" />
</template>
<template #home-hero-after> <template #home-hero-after>
<div class="hero-extensions"> <div class="hero-extensions">
<div class="hero-stats"> <div class="hero-stats">
@@ -120,6 +126,18 @@ onBeforeUnmount(() => {
padding-left: 16px; padding-left: 16px;
} }
.nav-logo {
width: 24px;
height: 24px;
margin-right: 8px;
flex-shrink: 0;
}
/* Hide the default VitePress logo image since we use our custom component */
:deep(.VPNavBarTitle .logo) {
display: none;
}
.skip-link { .skip-link {
position: absolute; position: absolute;
top: -100px; top: -100px;

View File

@@ -63,10 +63,8 @@
} }
/* Adjust sidebar and content layout */ /* Adjust sidebar and content layout */
.VPDoc, /* NOTE: Removed duplicate padding-left - VitePress already handles sidebar layout */
.VPDoc[data-v-343c73d6] { /* .VPDoc, .VPDoc[data-v-343c73d6] { padding-left: var(--vp-sidebar-width) !important; } */
padding-left: var(--vp-sidebar-width) !important;
}
/* Right side outline (TOC) adjustments */ /* Right side outline (TOC) adjustments */
.VPDocOutline { .VPDocOutline {
@@ -82,34 +80,41 @@
/* ============================================ /* ============================================
* Home Page Override * Home Page Override
* ============================================ */ * ============================================ */
.VPHome {
padding-bottom: 0;
}
/* Remove horizontal padding for home page only (has .VPHome class) */ /* Use :has() to detect home page (contains .pro-home) */
.VPHome .VPDoc { .VPContent:has(.pro-home) {
padding-left: 0 !important; padding: 0 !important;
padding-right: 0 !important; margin: 0 !important;
}
.VPHome .VPDoc .content-container {
padding-left: 0 !important;
padding-right: 0 !important;
max-width: 100% !important; max-width: 100% !important;
} }
.VPHome .VPDoc .content { .Layout:has(.pro-home) {
padding-left: 0 !important; max-width: 100% !important;
padding-right: 0 !important;
} }
.VPHome .VPContent { /* ProfessionalHome component full width */
.pro-home {
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
width: 100% !important;
}
/* Ensure all sections extend to full width */
.pro-home .hero-section,
.pro-home .features-section,
.pro-home .pipeline-section,
.pro-home .json-section,
.pro-home .quickstart-section,
.pro-home .cta-section {
width: 100% !important;
margin: 0 !important;
padding-left: 0 !important; padding-left: 0 !important;
padding-right: 0 !important; padding-right: 0 !important;
} }
.VPHomeHero { .VPHomeHero {
padding: 80px 24px; padding: 80px 0;
background: linear-gradient(180deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%); background: linear-gradient(180deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
} }