Skip to content

Commit ca2ba81

Browse files
joeVennerclaude
andcommitted
feat(ui): add theme toggle, enhanced search, and back to top button
Theme Toggle: - Create ThemeToggle.astro component with sun/moon icons - Add theme initialization in Head.astro to prevent flash - Store theme preference in localStorage - Keyboard shortcut: Cmd/Ctrl + Shift + L - Smooth icon transition animation Back to Top Button: - Create BackToTop.astro component - Appears after scrolling 400px - Smooth scroll to top on click - Fixed position bottom-right - Respects reduced motion preference Enhanced Search: - Recent searches functionality with localStorage - Show recent searches on focus when empty - Remove individual items or clear all - Enhanced empty state with icon and hint - Cmd/Ctrl + K keyboard shortcut to focus search - Add search to recent when getting results CSS Enhancements: - Theme toggle container styles - Recent searches section styling - Search empty state with icon - Search loading spinner - Keyboard shortcut hint styles - Print styles for new components Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4907bcd commit ca2ba81

6 files changed

Lines changed: 752 additions & 5 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
---
2+
// Back to Top Button Component
3+
---
4+
5+
<button
6+
id="back-to-top"
7+
class="back-to-top"
8+
aria-label="Back to top"
9+
title="Back to top"
10+
>
11+
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
12+
<path d="M18 15l-6-6-6 6"/>
13+
</svg>
14+
</button>
15+
16+
<script>
17+
(function() {
18+
const button = document.getElementById('back-to-top');
19+
if (!button) return;
20+
21+
// Show/hide button based on scroll position
22+
function toggleVisibility() {
23+
const scrollY = window.scrollY || document.documentElement.scrollTop;
24+
if (scrollY > 400) {
25+
button.classList.add('visible');
26+
} else {
27+
button.classList.remove('visible');
28+
}
29+
}
30+
31+
// Scroll to top with smooth behavior
32+
function scrollToTop() {
33+
window.scrollTo({
34+
top: 0,
35+
behavior: 'smooth'
36+
});
37+
}
38+
39+
// Throttled scroll handler
40+
let ticking = false;
41+
window.addEventListener('scroll', () => {
42+
if (!ticking) {
43+
window.requestAnimationFrame(() => {
44+
toggleVisibility();
45+
ticking = false;
46+
});
47+
ticking = true;
48+
}
49+
}, { passive: true });
50+
51+
// Click handler
52+
button.addEventListener('click', scrollToTop);
53+
54+
// Initial check
55+
toggleVisibility();
56+
})();
57+
</script>
58+
59+
<style>
60+
.back-to-top {
61+
position: fixed;
62+
bottom: 24px;
63+
right: 24px;
64+
width: 48px;
65+
height: 48px;
66+
border-radius: 50%;
67+
background: var(--color-accent);
68+
color: white;
69+
border: none;
70+
cursor: pointer;
71+
display: flex;
72+
align-items: center;
73+
justify-content: center;
74+
opacity: 0;
75+
visibility: hidden;
76+
transform: translateY(20px) scale(0.8);
77+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
78+
box-shadow: 0 4px 12px rgba(133, 52, 243, 0.4);
79+
z-index: 9999;
80+
}
81+
82+
.back-to-top.visible {
83+
opacity: 1;
84+
visibility: visible;
85+
transform: translateY(0) scale(1);
86+
}
87+
88+
.back-to-top:hover {
89+
background: var(--color-accent-hover);
90+
transform: translateY(-2px) scale(1.05);
91+
box-shadow: 0 6px 20px rgba(133, 52, 243, 0.5);
92+
}
93+
94+
.back-to-top:active {
95+
transform: translateY(0) scale(0.95);
96+
}
97+
98+
.back-to-top:focus-visible {
99+
outline: 2px solid var(--color-text-emphasis);
100+
outline-offset: 2px;
101+
}
102+
103+
/* Mobile adjustments */
104+
@media (max-width: 768px) {
105+
.back-to-top {
106+
bottom: 16px;
107+
right: 16px;
108+
width: 44px;
109+
height: 44px;
110+
}
111+
}
112+
113+
/* Respect reduced motion */
114+
@media (prefers-reduced-motion: reduce) {
115+
.back-to-top {
116+
transition: opacity 0.2s ease;
117+
transform: none;
118+
}
119+
120+
.back-to-top.visible {
121+
transform: none;
122+
}
123+
}
124+
</style>

website/src/components/Head.astro

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,30 @@ const twitterDomain =
4444
{socialImageUrl && <meta property="og:image:secure_url" content={socialImageUrl} />}
4545
{socialImageType && <meta property="og:image:type" content={socialImageType} />}
4646
{socialImageAlt && <meta name="twitter:image:alt" content={socialImageAlt} />}
47+
48+
<!-- Theme initialization script (runs early to prevent flash) -->
49+
<script is:inline>
50+
(function() {
51+
const STORAGE_KEY = 'awesome-copilot-theme';
52+
const stored = localStorage.getItem(STORAGE_KEY);
53+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
54+
const theme = stored || (prefersDark ? 'dark' : 'light');
55+
document.documentElement.setAttribute('data-theme', theme);
56+
})();
57+
</script>
58+
4759
<script is:inline define:vars={{ basePath }}>
60+
// Theme initialization - runs before page render to prevent flash
61+
(function() {
62+
const STORAGE_KEY = 'awesome-copilot-theme';
63+
const stored = localStorage.getItem(STORAGE_KEY);
64+
if (stored === 'dark') {
65+
document.documentElement.setAttribute('data-theme', 'dark');
66+
} else if (stored === 'light') {
67+
document.documentElement.setAttribute('data-theme', 'light');
68+
}
69+
})();
70+
4871
document.addEventListener('DOMContentLoaded', () => {
4972
document.body.dataset.basePath = basePath;
5073
});
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
---
2+
// Theme Toggle Component
3+
---
4+
5+
<button
6+
id="theme-toggle"
7+
class="theme-toggle"
8+
aria-label="Toggle dark mode"
9+
title="Toggle dark mode (Cmd/Ctrl + Shift + L)"
10+
>
11+
<span class="theme-toggle-icon theme-toggle-sun" aria-hidden="true">
12+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
13+
<circle cx="12" cy="12" r="5"/>
14+
<line x1="12" y1="1" x2="12" y2="3"/>
15+
<line x1="12" y1="21" x2="12" y2="23"/>
16+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
17+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
18+
<line x1="1" y1="12" x2="3" y2="12"/>
19+
<line x1="21" y1="12" x2="23" y2="12"/>
20+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
21+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
22+
</svg>
23+
</span>
24+
<span class="theme-toggle-icon theme-toggle-moon" aria-hidden="true">
25+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
26+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
27+
</svg>
28+
</span>
29+
</button>
30+
31+
<script>
32+
(function() {
33+
const STORAGE_KEY = 'awesome-copilot-theme';
34+
const toggle = document.getElementById('theme-toggle');
35+
const html = document.documentElement;
36+
37+
// Get initial theme
38+
function getTheme() {
39+
const stored = localStorage.getItem(STORAGE_KEY);
40+
if (stored === 'dark' || stored === 'light') return stored;
41+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
42+
}
43+
44+
// Apply theme
45+
function applyTheme(theme: string) {
46+
if (theme === 'dark') {
47+
html.setAttribute('data-theme', 'dark');
48+
document.body.classList.remove('light');
49+
document.body.classList.add('dark');
50+
} else {
51+
html.setAttribute('data-theme', 'light');
52+
document.body.classList.remove('dark');
53+
document.body.classList.add('light');
54+
}
55+
}
56+
57+
// Toggle theme
58+
function toggleTheme() {
59+
const current = getTheme();
60+
const next = current === 'dark' ? 'light' : 'dark';
61+
localStorage.setItem(STORAGE_KEY, next);
62+
applyTheme(next);
63+
}
64+
65+
// Initialize
66+
applyTheme(getTheme());
67+
68+
// Click handler
69+
toggle?.addEventListener('click', toggleTheme);
70+
71+
// Keyboard shortcut: Cmd/Ctrl + Shift + L
72+
document.addEventListener('keydown', (e) => {
73+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'L') {
74+
e.preventDefault();
75+
toggleTheme();
76+
}
77+
});
78+
79+
// Listen for system theme changes
80+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
81+
if (!localStorage.getItem(STORAGE_KEY)) {
82+
applyTheme(e.matches ? 'dark' : 'light');
83+
}
84+
});
85+
})();
86+
</script>
87+
88+
<style>
89+
.theme-toggle {
90+
display: flex;
91+
align-items: center;
92+
justify-content: center;
93+
width: 40px;
94+
height: 40px;
95+
padding: 0;
96+
border: 1px solid var(--color-glass-border);
97+
border-radius: var(--border-radius);
98+
background: var(--color-glass);
99+
color: var(--color-text);
100+
cursor: pointer;
101+
transition: all 0.2s ease;
102+
position: relative;
103+
overflow: hidden;
104+
}
105+
106+
.theme-toggle:hover {
107+
background: var(--color-bg-tertiary);
108+
border-color: var(--color-accent);
109+
transform: translateY(-1px);
110+
}
111+
112+
.theme-toggle:active {
113+
transform: translateY(0);
114+
}
115+
116+
.theme-toggle:focus-visible {
117+
outline: 2px solid var(--color-accent);
118+
outline-offset: 2px;
119+
}
120+
121+
.theme-toggle-icon {
122+
position: absolute;
123+
transition: all 0.3s ease;
124+
display: flex;
125+
align-items: center;
126+
justify-content: center;
127+
}
128+
129+
/* Sun icon - shown in dark mode */
130+
:root[data-theme="dark"] .theme-toggle-sun,
131+
body.dark .theme-toggle-sun {
132+
opacity: 1;
133+
transform: rotate(0deg) scale(1);
134+
}
135+
136+
:root[data-theme="dark"] .theme-toggle-moon,
137+
body.dark .theme-toggle-moon {
138+
opacity: 0;
139+
transform: rotate(90deg) scale(0.5);
140+
}
141+
142+
/* Moon icon - shown in light mode */
143+
:root[data-theme="light"] .theme-toggle-sun,
144+
body.light .theme-toggle-sun,
145+
:root:not([data-theme]) .theme-toggle-sun,
146+
body:not(.dark):not(.light) .theme-toggle-sun {
147+
opacity: 0;
148+
transform: rotate(-90deg) scale(0.5);
149+
}
150+
151+
:root[data-theme="light"] .theme-toggle-moon,
152+
body.light .theme-toggle-moon,
153+
:root:not([data-theme]) .theme-toggle-moon,
154+
body:not(.dark):not(.light) .theme-toggle-moon {
155+
opacity: 1;
156+
transform: rotate(0deg) scale(1);
157+
}
158+
</style>

website/src/pages/index.astro

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
33
import Modal from '../components/Modal.astro';
44
import Icon from '../components/Icon.astro';
5+
import ThemeToggle from '../components/ThemeToggle.astro';
6+
import BackToTop from '../components/BackToTop.astro';
57
68
const base = import.meta.env.BASE_URL;
79
---
@@ -29,6 +31,11 @@ const base = import.meta.env.BASE_URL;
2931
hasSidebar={false}
3032
>
3133
<div id="main-content">
34+
<!-- Theme Toggle - Fixed position -->
35+
<div class="theme-toggle-container">
36+
<ThemeToggle />
37+
</div>
38+
3239
<!-- Hero Section -->
3340
<section class="hero" aria-labelledby="hero-heading">
3441
<div class="hero-animated-bg" aria-hidden="true">
@@ -144,6 +151,9 @@ const base = import.meta.env.BASE_URL;
144151

145152
<Modal />
146153

154+
<!-- Back to Top Button -->
155+
<BackToTop />
156+
147157
<script>
148158
import '../scripts/pages/index';
149159
</script>

0 commit comments

Comments
 (0)