feat(ui): comprehensive UI/UX improvements (#1069)

* feat(ui): replace emoji icons with SVG icon system

Replace all emoji icons with a consistent SVG icon system to improve:
- Visual consistency across platforms
- Design token control and theming
- Professional appearance

Changes:
- Add new Icon.astro component with 16 custom SVG icons
- Update index.astro to use SVG icons in resource cards
- Update index.ts to render SVG icons in search results
- Update utils.ts to return icon names instead of emojis
- Update global.css with proper SVG icon styling
- Remove emoji from Footer component

Icons added: robot, document, lightning, hook, workflow, plug, wrench, book,
plus action icons: close, copy, download, share, external, plus, search, chevron-down

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ui): enhance hero section, add animations and mobile responsiveness

Phase 2 & 3 UI/UX improvements:

Hero Section Enhancements:
- Add gradient text effect for title (purple to orange gradient)
- Add animated floating particles in hero background
- Increase hero padding for better visual impact

Card Category Colors:
- Add category-specific accent colors (AI, docs, power, automation, etc.)
- Each category has unique glow effect on hover
- Category colors: purple (AI), orange (docs), red (power), etc.

Entrance Animations:
- Add staggered fade-in animation for cards (50ms delay each)
- Cards animate in with translateY + opacity
- Add slide-down animation for search results

Mobile Responsiveness:
- Responsive grid: 4 cols → 2 cols → 1 col
- Adjust font sizes for mobile screens
- Add safe-area-inset support for notched devices
- Ensure touch targets ≥44px

Accessibility:
- Add prefers-reduced-motion support
- Disable animations for users who prefer reduced motion
- Smooth scroll with fallback

Additional Improvements:
- Add arrow indicator on card hover
- Add loading animation for count numbers
- Enhanced scrollbar styling
- Print styles for better printing

Co-Authored-By: Claude <noreply@anthropic.com>

* 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>

* fix(ui): resolve header and theme toggle issues

- Add Copilot logo to header via Starlight config with automatic theme switching
- Fix theme toggle slider direction (was reversed)
- Fix theme toggle active icon highlighting (was backwards)
- Change theme toggle from purple circle slider to bold text indicator
- Fix theme toggle slider overflow by adding overflow: hidden
- Remove duplicate banner image from home page
- Clean up conflicting logo CSS rules to prevent duplication

The header now displays: [ Copilot Icon ] Awesome GitHub Copilot [ Search ]
Theme toggle indicators are now visually clear with bold text for selected theme.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(ui): address feedback on UI/UX improvements

- Remove logo from header per brand guidance (logo config and CSS)
- Fix back-to-top button visibility by moving to body level and using global styles
- Fix modal visibility by adding 'visible' class for CSS animations
- Fix theme toggle applying site-wide by using global styles and proper theme initialization
- Update icons to use GitHub Primer SVG icons with proper fill-based styling
- Fix plugin modal to render SVG icons instead of icon names
- Add theme initialization script to prevent flash of unstyled content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move modal to body level to fix z-index stacking context issue

The modal was nested inside .main-pane which has isolation: isolate,
creating a new stacking context. This caused the modal's z-index
to be evaluated within that context, unable to stack above the header.

This fix moves the modal to be a direct child of body on page load,
allowing it to properly cover the entire viewport including navbar.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JoeVenner
2026-03-23 00:13:29 +01:00
committed by GitHub
parent c50b3563f8
commit 10e717202f
22 changed files with 1815 additions and 171 deletions

View File

@@ -0,0 +1,129 @@
---
// Back to Top Button Component
---
<button
id="back-to-top"
class="back-to-top"
aria-label="Back to top"
title="Back to top"
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
<script>
(function() {
const button = document.getElementById('back-to-top');
if (!button) return;
// Move button to body level to escape stacking contexts
if (button.parentElement !== document.body) {
document.body.appendChild(button);
}
// Show/hide button based on scroll position
function toggleVisibility() {
const scrollY = window.scrollY || document.documentElement.scrollTop;
if (scrollY > 400) {
button.classList.add('visible');
} else {
button.classList.remove('visible');
}
}
// Scroll to top with smooth behavior
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
// Throttled scroll handler
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
toggleVisibility();
ticking = false;
});
ticking = true;
}
}, { passive: true });
// Click handler
button.addEventListener('click', scrollToTop);
// Initial check
toggleVisibility();
})();
</script>
<style is:global>
.back-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-accent);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transform: translateY(20px) scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(133, 52, 243, 0.4);
z-index: 9999;
}
.back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.back-to-top:hover {
background: var(--color-accent-hover);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 6px 20px rgba(133, 52, 243, 0.5);
}
.back-to-top:active {
transform: translateY(0) scale(0.95);
}
.back-to-top:focus-visible {
outline: 2px solid var(--color-text-emphasis);
outline-offset: 2px;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.back-to-top {
bottom: 16px;
right: 16px;
width: 44px;
height: 44px;
}
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.back-to-top {
transition: opacity 0.2s ease;
transform: none;
}
.back-to-top.visible {
transform: none;
}
}
</style>

View File

@@ -4,6 +4,7 @@ import LastUpdated from "@astrojs/starlight/components/LastUpdated.astro";
import Pagination from "@astrojs/starlight/components/Pagination.astro";
import config from "virtual:starlight/user-config";
import { Icon } from "@astrojs/starlight/components";
import ThemeToggle from "./ThemeToggle.astro";
---
<footer class="sl-flex">
@@ -21,9 +22,11 @@ import { Icon } from "@astrojs/starlight/components";
)
}
<p class="made-by">Made with ❤️ by our amazing <a href="/contributors/">contributors</a></p>
<p class="made-by">Made with love by our amazing <a href="/contributors/">contributors</a></p>
</footer>
<ThemeToggle />
<style>
footer {
flex-direction: column;
@@ -81,4 +84,4 @@ import { Icon } from "@astrojs/starlight/components";
.made-by a:hover {
color: var(--sl-color-accent);
}
</style>
</style>

View File

@@ -44,7 +44,29 @@ const twitterDomain =
{socialImageUrl && <meta property="og:image:secure_url" content={socialImageUrl} />}
{socialImageType && <meta property="og:image:type" content={socialImageType} />}
{socialImageAlt && <meta name="twitter:image:alt" content={socialImageAlt} />}
<!-- Theme initialization script (runs early to prevent flash) -->
<script is:inline>
(function() {
const STORAGE_KEY = 'awesome-copilot-theme';
const stored = localStorage.getItem(STORAGE_KEY);
// Theme handling:
// - 'dark' or 'light' → set data-theme attribute to that value
// - 'auto' or unset → don't set data-theme (CSS media query handles system preference)
if (stored === 'dark' || stored === 'light') {
document.documentElement.setAttribute('data-theme', stored);
}
// For 'auto' or unset, no attribute means CSS media query controls theme
})();
</script>
<script is:inline define:vars={{ basePath }}>
// basePath setup for runtime use
document.addEventListener('DOMContentLoaded', () => {
document.body.dataset.basePath = basePath;
});
document.addEventListener('DOMContentLoaded', () => {
document.body.dataset.basePath = basePath;
});

View File

@@ -0,0 +1,120 @@
---
// Icon component with SVG icons
// Icons are either fill-based (from GitHub Primer) or stroke-based (custom)
// GitHub Primer icons are sourced from https://primer.style/foundations/icons/
export interface Props {
name: 'agents' | 'instructions' | 'skills' | 'hooks' | 'workflows' | 'plugins' | 'tools' | 'learning' | 'close' | 'copy' | 'download' | 'share' | 'external' | 'plus' | 'search' | 'chevron-down' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book' | 'robot' | 'sync';
size?: number;
class?: string;
}
const { name, size = 24, class: className = '' } = Astro.props;
// Icon definitions: { path: SVG path(s), fill: true for fill-based icons }
const icons: Record<string, { path: string; fill?: boolean }> = {
// Resource type icons - using GitHub Primer icons where available
// Agent icon - using GitHub Primer's agent-24 (sparkle over workflow)
// Source: https://primer.style/foundations/icons/agent-24
'robot': {
fill: true,
path: `<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>`
},
// Document icon - custom stroke-based
'document': {
path: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
// Lightning icon - custom stroke-based (for skills)
'lightning': {
path: `<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
// Hook icon - using GitHub Primer's sync-24 (represents hooks/iterations)
// Source: https://primer.style/foundations/icons/sync-24
'hook': {
fill: true,
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
},
// Workflow icon - using GitHub Primer's workflow-24
// Source: https://primer.style/foundations/icons/workflow-24
// Also used by https://github.github.com/gh-aw/
'workflow': {
fill: true,
path: `<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>`
},
// Plug icon - using GitHub Primer's plug-24
// Source: https://primer.style/foundations/icons/plug-24
'plug': {
fill: true,
path: `<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>`
},
// Wrench icon - custom stroke-based (for tools)
'wrench': {
path: `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
// Book icon - custom stroke-based (for learning)
'book': {
path: `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
// Action icons - all custom stroke-based
'close': {
path: `<path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'copy': {
path: `<path d="M8 4h8a2 2 0 0 1 2 2v8M8 4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 4v10a2 2 0 0 0 2 2h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'download': {
path: `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'share': {
path: `<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8M16 6l-4-4-4 4M12 2v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'external': {
path: `<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'plus': {
path: `<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'search': {
path: `<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
'chevron-down': {
path: `<path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
},
// Alias for hook - same as 'hook'
'sync': {
fill: true,
path: `<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>`
},
};
const iconData = icons[name] || { path: '' };
const isFill = iconData.fill ?? false;
const iconPath = iconData.path;
---
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill={isFill ? 'currentColor' : 'none'}
class={className}
aria-hidden="true"
set:html={iconPath}
/>

View File

@@ -1,10 +1,13 @@
---
import Icon from './Icon.astro';
interface Props {
title: string;
description: string;
icon?: 'robot' | 'document' | 'lightning' | 'hook' | 'workflow' | 'plug' | 'wrench' | 'book';
}
const { title, description } = Astro.props;
const { title, description, icon } = Astro.props;
const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CONTRIBUTING.md';
---
@@ -12,7 +15,10 @@ const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CON
<div class="container">
<div class="page-header-row">
<div>
<h1><Fragment set:html={title} /></h1>
<h1>
{icon && <Icon name={icon} size={28} />}
<Fragment set:html={title} />
</h1>
<p><slot><Fragment set:html={description} /></slot></p>
</div>
<a href={contributingUrl} class="contribute-link" target="_blank" rel="noopener">
@@ -22,3 +28,11 @@ const contributingUrl = 'https://github.com/github/awesome-copilot/blob/main/CON
</div>
</div>
</div>
<style>
.page-header h1 {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,170 @@
---
// Theme Toggle Component - 3 state slider: Auto | Dark | Light
---
<div class="theme-toggle-container">
<button
id="theme-toggle"
class="theme-toggle"
aria-label="Toggle theme"
title="Change theme"
>
<span class="theme-icon moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</span>
<span class="theme-icon auto" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2v10l4.5 4.5"/>
</svg>
</span>
<span class="theme-icon sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<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>
</span>
<span class="theme-slider"></span>
</button>
</div>
<script>
(function() {
// Move theme toggle to body level to escape any stacking contexts
const container = document.querySelector('.theme-toggle-container');
if (container && container.parentElement !== document.body) {
document.body.appendChild(container);
}
const STORAGE_KEY = 'awesome-copilot-theme';
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
const themes = ['dark', 'auto', 'light'];
const icons = ['moon', 'auto', 'sun'];
function getThemeIndex() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && themes.includes(stored)) {
return themes.indexOf(stored);
}
// Default to light theme
return 2;
}
function applyTheme(index: number) {
const theme = themes[index];
if (theme === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
html.setAttribute('data-theme', theme);
}
// Move slider
const slider = toggle?.querySelector('.theme-slider') as HTMLElement;
if (slider) {
slider.style.transform = `translateX(${index * 100}%)`;
}
// Highlight active icon
const themeToggle = document.querySelector('.theme-toggle');
themeToggle?.setAttribute('data-active', String(index));
}
function cycleTheme() {
const currentIndex = getThemeIndex();
const nextIndex = (currentIndex + 1) % themes.length;
localStorage.setItem(STORAGE_KEY, themes[nextIndex]);
applyTheme(nextIndex);
}
// Initialize
applyTheme(getThemeIndex());
// Click handler
toggle?.addEventListener('click', cycleTheme);
// Keyboard shortcut
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'L') {
e.preventDefault();
cycleTheme();
}
});
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem(STORAGE_KEY) === 'auto') {
applyTheme(1); // auto
}
});
})();
</script>
<style is:global>
.theme-toggle-container {
display: flex;
align-items: center;
}
.theme-toggle {
display: flex;
position: relative;
width: 108px;
height: 36px;
padding: 3px;
border: 1px solid var(--color-border);
border-radius: 20px;
background: var(--color-bg-secondary);
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
}
.theme-toggle:hover {
border-color: var(--color-accent);
}
.theme-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.theme-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 30px;
border-radius: 16px;
color: var(--color-text-muted);
transition: all 0.2s ease;
z-index: 1;
font-weight: 400;
}
.theme-icon.moon { color: #9898a6; }
.theme-icon.auto { color: #9898a6; }
.theme-icon.sun { color: #9898a6; }
.theme-toggle[data-active="0"] .moon,
.theme-toggle[data-active="1"] .auto,
.theme-toggle[data-active="2"] .sun {
color: var(--color-text);
font-weight: 700;
}
.theme-slider {
display: none;
}
</style>