d668aa0fdf
Replaces Astro Rocket demo content with Armarium branding and adds complete DE/FR/IT/EN translations across all pages. Branding & content (v0.7.0): - Add horizontal SVG logo to navbar with currentColor dark mode support - Rewrite homepage with Armarium hero, 6 feature cards, trust bar, Zürich coat of arms SVG, and CTA; shared HomePage.astro component - Add privacy page (/datenschutz) with 6 Infomaniak certification cards and 8-section policy (ISO 27001:2022, Swiss Hosting, nDSG/GDPR, etc.) - Add legal notice page (/impressum) - Rewrite about, contact, 404 pages with Armarium content - Add features page (/projects) from projects content collection - Add language switcher dropdown (LanguageSwitcherDropdown.astro) - Add single launch blog post; remove all demo blog/project content - Set up i18n foundation: astro.config.mjs, ui.ts, utils.ts Full i18n (v0.8.0): - Add all pages in FR/IT/EN: about, contact, blog, features, privacy, legal notice — 28 locale variants total - Language switcher visible in every layout (PageLayout, BlogLayout, ProjectLayout, LandingLayout) with translated nav items - Locale-aware nav and footer hrefs via nav.*.href keys in ui.ts - Shared page components (AboutPage, ContactPage, FeaturesIndexPage, BlogIndexPage) accept locale prop; locale pages are 4-line wrappers - Extend content.config.ts locale enum with de and it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
772 lines
26 KiB
Plaintext
772 lines
26 KiB
Plaintext
---
|
|
/**
|
|
* Header Component
|
|
* Flexible navigation header with variant-based configuration
|
|
*
|
|
* Variants:
|
|
* - layout: 'default' | 'centered' | 'minimal'
|
|
* - position: 'fixed' | 'sticky' | 'static'
|
|
* - size: 'sm' | 'md' | 'lg'
|
|
* - variant: 'default' | 'solid' | 'transparent'
|
|
* - colorScheme: 'default' | 'invert' (use 'invert' for dark backgrounds)
|
|
* - shape: 'bar' | 'floating' (use 'floating' for capsule header)
|
|
*
|
|
* Features:
|
|
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
|
|
* - Optional CTA button with customization
|
|
* - Mobile menu with Escape key support
|
|
* - Theme toggle
|
|
* - GitHub/action buttons
|
|
* - Full slot support for customization
|
|
* - Inverted color scheme for use on dark/image backgrounds
|
|
* - Floating capsule shape with scroll-reactive bg + color flip
|
|
*/
|
|
import type { HTMLAttributes } from 'astro/types';
|
|
import { cn } from '@/lib/cn';
|
|
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
|
|
import { headerVariants, headerInnerVariants } from './header.variants';
|
|
import Button from '@/components/ui/form/Button/Button.astro';
|
|
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
|
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
|
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
|
|
import ThemeSelector from '@/components/layout/ThemeSelector.astro';
|
|
import ThemeSelectorDropdown from '@/components/layout/ThemeSelectorDropdown.astro';
|
|
import LanguageSwitcherDropdown from '@/components/layout/LanguageSwitcherDropdown.astro';
|
|
import siteConfig from '@/config/site.config';
|
|
import type { Locale } from '@/i18n/ui';
|
|
|
|
export interface NavItem {
|
|
label: string;
|
|
href: string;
|
|
}
|
|
|
|
export interface HeaderAction {
|
|
icon: string;
|
|
href: string;
|
|
label: string;
|
|
iconOnly?: boolean;
|
|
target?: string;
|
|
}
|
|
|
|
interface Props extends HTMLAttributes<'header'> {
|
|
/** Layout style: default (logo left, nav right), centered (logo center), minimal (logo + cta only) */
|
|
layout?: 'default' | 'centered' | 'minimal';
|
|
/** Position behavior */
|
|
position?: 'fixed' | 'sticky' | 'static';
|
|
/** Header height */
|
|
size?: 'sm' | 'md' | 'lg';
|
|
/** Background variant */
|
|
variant?: 'default' | 'solid' | 'transparent';
|
|
/** Color scheme for text/icons - use 'invert' for dark backgrounds */
|
|
colorScheme?: 'default' | 'invert';
|
|
/** Shape: 'bar' (full-width, default) or 'floating' (centered capsule) */
|
|
shape?: 'bar' | 'floating';
|
|
/** Override default navigation (replaces getNavRoutes()) */
|
|
nav?: NavItem[];
|
|
/** Additional navigation items (e.g., #features for landing pages) */
|
|
extraNav?: NavItem[];
|
|
/** Show CTA button */
|
|
showCta?: boolean;
|
|
/** CTA button configuration */
|
|
cta?: { label?: string; href?: string; icon?: string };
|
|
/** Action buttons (GitHub, etc.) */
|
|
actions?: HeaderAction[];
|
|
/** Show theme toggle (default: true) */
|
|
showThemeToggle?: boolean;
|
|
/** Show colour-theme selector swatches */
|
|
showThemeSelector?: boolean;
|
|
/** Show mobile menu (default: true) */
|
|
showMobileMenu?: boolean;
|
|
/** Show active state for current page (default: true) */
|
|
showActiveState?: boolean;
|
|
/** Logo text override */
|
|
logoText?: string;
|
|
/** Hide logo entirely */
|
|
hideLogo?: boolean;
|
|
/** Show language switcher */
|
|
showLanguageSwitcher?: boolean;
|
|
/** Current locale for language switcher */
|
|
currentLocale?: Locale;
|
|
/** Show social icon links (desktop/tablet only, reads from siteConfig.socialLinks) */
|
|
showSocialLinks?: boolean;
|
|
/** Show scroll progress bar at the bottom of the header */
|
|
showScrollProgress?: boolean;
|
|
/** Position of the scroll progress bar: 'top' (above header) or 'bottom' (below header, default) */
|
|
scrollProgressPosition?: 'top' | 'bottom';
|
|
}
|
|
|
|
const {
|
|
layout = 'default',
|
|
position = 'fixed',
|
|
size = 'lg',
|
|
variant = 'solid',
|
|
colorScheme = 'default',
|
|
shape = 'bar',
|
|
nav,
|
|
extraNav = [],
|
|
showCta = false,
|
|
cta = { label: 'Start a project', href: '/contact' },
|
|
actions = [],
|
|
showThemeToggle = true,
|
|
showThemeSelector = false,
|
|
showMobileMenu = true,
|
|
showSocialLinks = false,
|
|
showActiveState = true,
|
|
showScrollProgress = false,
|
|
scrollProgressPosition = 'bottom',
|
|
showLanguageSwitcher = false,
|
|
currentLocale = 'de',
|
|
logoText,
|
|
hideLogo = false,
|
|
class: className,
|
|
...attrs
|
|
} = Astro.props;
|
|
|
|
// Shape + color scheme helpers
|
|
const isFloating = shape === 'floating';
|
|
const isInvert = colorScheme === 'invert';
|
|
|
|
// Get navigation items
|
|
const defaultNav = getNavItems().map((item: NavConfigItem) => ({
|
|
label: item.label,
|
|
href: item.href,
|
|
}));
|
|
const navItems: NavItem[] = nav || [...extraNav, ...defaultNav];
|
|
|
|
// Current path for active state
|
|
const currentPath = Astro.url.pathname;
|
|
|
|
// Check if we're on the landing page
|
|
const isLandingPage = currentPath === '/';
|
|
|
|
// Process CTA href for landing page anchor links
|
|
const ctaHref = cta.href?.startsWith('#') && !isLandingPage ? `/${cta.href}` : cta.href;
|
|
|
|
// Check slots
|
|
const hasLogoSlot = Astro.slots.has('logo');
|
|
const hasNavSlot = Astro.slots.has('nav');
|
|
const hasActionsSlot = Astro.slots.has('actions');
|
|
const hasMobileMenuSlot = Astro.slots.has('mobile-menu');
|
|
|
|
// Compute header classes
|
|
const headerClasses = cn(
|
|
headerVariants({ position, variant, shape }),
|
|
isInvert && !isFloating && 'invert-section',
|
|
className
|
|
);
|
|
|
|
// Compute inner container classes
|
|
const innerClasses = headerInnerVariants({ size, shape });
|
|
|
|
// Check if a nav item is active
|
|
function isActive(href: string): boolean {
|
|
if (!showActiveState) return false;
|
|
if (href.startsWith('#')) return false;
|
|
return currentPath === href || currentPath.startsWith(href + '/');
|
|
}
|
|
|
|
// Map a social URL to its icon name + accessible label
|
|
function getSocialIconData(url: string): { icon: string; label: string } {
|
|
if (url.includes('github.com')) return { icon: 'github', label: 'GitHub' };
|
|
if (url.includes('instagram.com')) return { icon: 'instagram', label: 'Instagram' };
|
|
if (url.includes('x.com') || url.includes('twitter.com')) return { icon: 'x-twitter', label: 'X' };
|
|
if (url.includes('linkedin.com')) return { icon: 'linkedin', label: 'LinkedIn' };
|
|
if (url.includes('bsky.app')) return { icon: 'bluesky', label: 'Bluesky' };
|
|
return { icon: 'link', label: 'Social' };
|
|
}
|
|
|
|
// Generate unique ID for this header instance
|
|
const menuId = `mobile-menu-${Math.random().toString(36).slice(2, 9)}`;
|
|
const buttonId = `${menuId}-button`;
|
|
---
|
|
|
|
<header
|
|
class={headerClasses}
|
|
data-menu-id={menuId}
|
|
data-button-id={buttonId}
|
|
data-header-shape={shape}
|
|
data-header-variant={variant}
|
|
data-header-color-scheme={colorScheme}
|
|
{...attrs}
|
|
>
|
|
<div class={innerClasses}>
|
|
{/* Logo */}
|
|
{
|
|
!hideLogo &&
|
|
(hasLogoSlot ? (
|
|
<slot name="logo" />
|
|
) : (
|
|
<a href="/" class="flex items-center">
|
|
<Logo
|
|
variant="full"
|
|
size={size === 'lg' ? 'lg' : 'md'}
|
|
forceDark={isInvert}
|
|
class={cn(isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-foreground'))}
|
|
/>
|
|
</a>
|
|
))
|
|
}
|
|
|
|
{/* Desktop Navigation */}
|
|
{
|
|
layout !== 'minimal' &&
|
|
(hasNavSlot ? (
|
|
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
|
<slot name="nav" />
|
|
</nav>
|
|
) : (
|
|
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
|
|
{navItems.map(({ label, href }) => (
|
|
<a
|
|
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
|
class={cn(
|
|
'nav-link relative rounded-md px-3 py-2 text-sm',
|
|
'transition-all duration-(--transition-fast)',
|
|
isFloating && 'hdr-invert-text',
|
|
isFloating
|
|
? (isActive(href)
|
|
? 'hdr-nav-active font-semibold'
|
|
: 'font-medium opacity-80 hover:opacity-100')
|
|
: (isActive(href)
|
|
? 'nav-link-active font-semibold text-foreground bg-secondary'
|
|
: 'nav-link-inactive font-medium text-foreground-muted hover:text-foreground hover:bg-secondary/70')
|
|
)}
|
|
aria-current={isActive(href) ? 'page' : undefined}
|
|
>
|
|
{label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
))
|
|
}
|
|
|
|
{/* Actions Area */}
|
|
<div class="flex items-center gap-2 justify-self-end">
|
|
{
|
|
hasActionsSlot ? (
|
|
<slot name="actions" />
|
|
) : (
|
|
<>
|
|
{showThemeToggle && (
|
|
<ThemeToggle class={isFloating ? 'hdr-invert-text' : undefined} />
|
|
)}
|
|
|
|
{showThemeSelector && (
|
|
<div class="hidden md:flex">
|
|
<ThemeSelectorDropdown class={isFloating ? 'hdr-invert-text' : undefined} />
|
|
</div>
|
|
)}
|
|
|
|
{showLanguageSwitcher && (
|
|
<div class="hidden md:flex">
|
|
<LanguageSwitcherDropdown currentLocale={currentLocale as Locale} />
|
|
</div>
|
|
)}
|
|
|
|
{showSocialLinks && siteConfig.socialLinks.length > 0 && (
|
|
<div class="hidden md:flex items-center gap-0.5">
|
|
{siteConfig.socialLinks.map((url) => {
|
|
const { icon, label } = getSocialIconData(url);
|
|
return (
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label={label}
|
|
class={cn(
|
|
'rounded-md p-2 transition-colors duration-(--transition-fast)',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
isFloating
|
|
? 'hdr-invert-text'
|
|
: 'text-foreground-muted hover:text-foreground hover:bg-secondary/70'
|
|
)}
|
|
>
|
|
<Icon name={icon} size="md" />
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{actions.map((action) => (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
icon={action.iconOnly}
|
|
href={action.href}
|
|
target={action.target}
|
|
aria-label={action.label}
|
|
class={isFloating ? 'hdr-invert-text' : undefined}
|
|
>
|
|
<Icon name={action.icon} size="sm" />
|
|
{!action.iconOnly && action.label}
|
|
</Button>
|
|
))}
|
|
|
|
{showCta && (
|
|
<div class="hidden md:flex">
|
|
<Button
|
|
size="sm"
|
|
href={ctaHref}
|
|
target={ctaHref?.startsWith('http') ? '_blank' : undefined}
|
|
class={cn('hdr-cta-brand', isFloating ? 'hdr-invert-cta' : undefined)}
|
|
>
|
|
{cta.icon && <Icon name={cta.icon} size="sm" />}
|
|
{cta.label}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
{/* Mobile Menu Toggle */}
|
|
{
|
|
showMobileMenu && layout !== 'minimal' && (
|
|
<button
|
|
type="button"
|
|
id={buttonId}
|
|
class={cn(
|
|
'inline-flex items-center justify-center rounded-md p-2 md:hidden',
|
|
'transition-colors',
|
|
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
|
isFloating
|
|
? 'hdr-invert-text'
|
|
: 'text-foreground-muted hover:text-foreground hover:bg-secondary'
|
|
)}
|
|
aria-expanded="false"
|
|
aria-controls={menuId}
|
|
aria-label="Toggle menu"
|
|
>
|
|
<span class="menu-icon">
|
|
<Icon name="menu" size="md" />
|
|
</span>
|
|
<span class="close-icon hidden">
|
|
<Icon name="x" size="md" />
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scroll Progress Bar */}
|
|
{showScrollProgress && (
|
|
<div
|
|
id="scroll-progress-bar"
|
|
class={`absolute left-0 h-[2px] w-0 bg-brand-500 transition-none ${scrollProgressPosition === 'top' ? 'top-0' : 'bottom-0'}`}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile Menu */}
|
|
{
|
|
showMobileMenu &&
|
|
layout !== 'minimal' &&
|
|
(hasMobileMenuSlot ? (
|
|
<div
|
|
id={menuId}
|
|
class={cn(
|
|
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
|
isFloating
|
|
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
|
: 'border-border bg-background border-t'
|
|
)}
|
|
role="navigation"
|
|
aria-label="Mobile navigation"
|
|
>
|
|
<slot name="mobile-menu" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
id={menuId}
|
|
class={cn(
|
|
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
|
|
isFloating
|
|
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
|
|
: 'border-border bg-background border-t'
|
|
)}
|
|
role="navigation"
|
|
aria-label="Mobile navigation"
|
|
>
|
|
<div class={cn(
|
|
'space-y-1 py-4',
|
|
isFloating ? 'px-4' : 'mx-auto max-w-6xl px-6'
|
|
)}>
|
|
{navItems.map(({ label, href }) => (
|
|
<a
|
|
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
|
|
class={cn(
|
|
'mobile-nav-link block rounded-md px-3 py-2 text-sm',
|
|
'transition-all duration-(--transition-fast)',
|
|
isActive(href)
|
|
? 'mobile-nav-link-active bg-secondary text-foreground font-semibold'
|
|
: 'mobile-nav-link-inactive text-foreground-muted hover:bg-secondary/70 hover:text-foreground font-medium'
|
|
)}
|
|
aria-current={isActive(href) ? 'page' : undefined}
|
|
>
|
|
{label}
|
|
</a>
|
|
))}
|
|
|
|
{showCta && (
|
|
<div class="border-border mt-3 border-t pt-3">
|
|
<Button fullWidth href={ctaHref} target={ctaHref?.startsWith('http') ? '_blank' : undefined}>
|
|
{cta.label}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{showThemeSelector && (
|
|
<div class="border-border mt-3 border-t pt-3">
|
|
<div class="flex items-center justify-between px-1">
|
|
<span class="text-sm text-foreground-muted">Colour theme</span>
|
|
<ThemeSelector />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showLanguageSwitcher && (
|
|
<div class="border-border mt-3 border-t pt-3">
|
|
<div class="flex items-center justify-between px-1">
|
|
<span class="text-sm text-foreground-muted">Language</span>
|
|
<LanguageSwitcherDropdown currentLocale={currentLocale as Locale} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
}
|
|
</header>
|
|
|
|
{/* Mobile Menu Backdrop - positioned outside header to blur page content */}
|
|
{
|
|
showMobileMenu && layout !== 'minimal' && (
|
|
<div
|
|
id={`${menuId}-backdrop`}
|
|
class="pointer-events-none fixed inset-0 z-40 opacity-0 transition-opacity duration-200 md:hidden"
|
|
aria-hidden="true"
|
|
/>
|
|
)
|
|
}
|
|
|
|
<script>
|
|
function initMobileMenu() {
|
|
const menuHeaders = document.querySelectorAll<HTMLElement>('header[data-menu-id]');
|
|
menuHeaders.forEach((header) => {
|
|
const menuId = header.dataset.menuId!;
|
|
const buttonId = header.dataset.buttonId!;
|
|
const isFloating = header.dataset.headerShape === 'floating';
|
|
|
|
const button = document.getElementById(buttonId);
|
|
const menu = document.getElementById(menuId);
|
|
const backdrop = document.getElementById(`${menuId}-backdrop`);
|
|
const menuIcon = button?.querySelector('.menu-icon');
|
|
const closeIcon = button?.querySelector('.close-icon');
|
|
|
|
if (!button || !menu || !menuIcon || !closeIcon) return;
|
|
if (button.dataset.menuInit) return;
|
|
button.dataset.menuInit = 'true';
|
|
|
|
let isOpen = false;
|
|
let isAnimating = false;
|
|
|
|
function open() {
|
|
if (isOpen || isAnimating) return;
|
|
isAnimating = true;
|
|
isOpen = true;
|
|
|
|
button!.setAttribute('aria-expanded', 'true');
|
|
menuIcon!.classList.add('hidden');
|
|
closeIcon!.classList.remove('hidden');
|
|
|
|
if (isFloating) {
|
|
// Force scrolled state + flatten bottom corners
|
|
header.setAttribute('data-scrolled', '');
|
|
header.classList.remove('rounded-2xl');
|
|
header.classList.add('rounded-t-2xl');
|
|
} else {
|
|
header.classList.add('!bg-background');
|
|
}
|
|
|
|
// Fade out and blur the page content
|
|
const mainContent = document.querySelector('main');
|
|
const footer = document.querySelector('footer');
|
|
if (mainContent) mainContent.classList.add('mobile-menu-blur');
|
|
if (footer) footer.classList.add('mobile-menu-blur');
|
|
|
|
// Show menu and backdrop with animations
|
|
menu!.classList.remove('hidden', 'animate-menu-up', 'opacity-0', 'scale-y-0');
|
|
menu!.classList.add('animate-menu-down');
|
|
if (backdrop) {
|
|
backdrop.classList.remove('pointer-events-none', 'animate-backdrop-out');
|
|
backdrop.classList.add('animate-backdrop');
|
|
}
|
|
|
|
isAnimating = false;
|
|
}
|
|
|
|
function close() {
|
|
if (!isOpen || isAnimating) return;
|
|
isAnimating = true;
|
|
|
|
button!.setAttribute('aria-expanded', 'false');
|
|
menuIcon!.classList.remove('hidden');
|
|
closeIcon!.classList.add('hidden');
|
|
|
|
// Start closing animation
|
|
menu!.classList.remove('animate-menu-down');
|
|
menu!.classList.add('animate-menu-up');
|
|
if (backdrop) {
|
|
backdrop.classList.remove('animate-backdrop');
|
|
backdrop.classList.add('animate-backdrop-out');
|
|
}
|
|
|
|
// Restore page content
|
|
const mainContent = document.querySelector('main');
|
|
const footer = document.querySelector('footer');
|
|
if (mainContent) mainContent.classList.remove('mobile-menu-blur');
|
|
if (footer) footer.classList.remove('mobile-menu-blur');
|
|
|
|
// Wait for animation to complete before hiding
|
|
setTimeout(() => {
|
|
menu!.classList.add('hidden', 'opacity-0', 'scale-y-0');
|
|
if (backdrop) {
|
|
backdrop.classList.add('pointer-events-none');
|
|
}
|
|
|
|
if (isFloating) {
|
|
// Restore rounded corners
|
|
header.classList.remove('rounded-t-2xl');
|
|
header.classList.add('rounded-2xl');
|
|
// Only remove scrolled if actually at top
|
|
if (window.scrollY <= 60) {
|
|
header.removeAttribute('data-scrolled');
|
|
}
|
|
} else {
|
|
header.classList.remove('!bg-background');
|
|
}
|
|
|
|
isOpen = false;
|
|
isAnimating = false;
|
|
}, 200);
|
|
}
|
|
|
|
function toggle() {
|
|
if (isOpen) {
|
|
close();
|
|
} else {
|
|
open();
|
|
}
|
|
}
|
|
|
|
button.addEventListener('click', toggle);
|
|
|
|
// Close on backdrop click
|
|
if (backdrop) {
|
|
backdrop.addEventListener('click', close);
|
|
}
|
|
|
|
// Close on Escape key
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && isOpen) {
|
|
close();
|
|
}
|
|
});
|
|
|
|
// Close when clicking on mobile menu links
|
|
menu.querySelectorAll('a').forEach((link) => {
|
|
link.addEventListener('click', close);
|
|
});
|
|
});
|
|
}
|
|
|
|
initMobileMenu();
|
|
document.addEventListener('astro:page-load', initMobileMenu);
|
|
document.addEventListener('astro:after-swap', initMobileMenu);
|
|
</script>
|
|
|
|
<script>
|
|
const SCROLL_THRESHOLD = 60;
|
|
const BAR_SCROLLED_CLASSES = ['bg-background/80', 'backdrop-blur-lg', 'border-b', 'border-border/50'];
|
|
|
|
function initScrollWatcher() {
|
|
const scrollHeaders = document.querySelectorAll<HTMLElement>('header[data-header-shape="floating"], header[data-header-shape="bar"]');
|
|
scrollHeaders.forEach((header) => {
|
|
if (header.dataset.scrollInit) return;
|
|
header.dataset.scrollInit = 'true';
|
|
|
|
const isBar = header.dataset.headerShape === 'bar';
|
|
const isTransparentBar = isBar && header.dataset.headerVariant === 'transparent';
|
|
let ticking = false;
|
|
|
|
function onScroll() {
|
|
if (ticking) return;
|
|
ticking = true;
|
|
|
|
requestAnimationFrame(() => {
|
|
if (window.scrollY > SCROLL_THRESHOLD) {
|
|
header.setAttribute('data-scrolled', '');
|
|
if (isTransparentBar) {
|
|
header.classList.add(...BAR_SCROLLED_CLASSES);
|
|
header.classList.remove('bg-transparent');
|
|
}
|
|
} else {
|
|
// Don't remove if mobile menu is open
|
|
const menuId = header.dataset.menuId;
|
|
const menu = menuId ? document.getElementById(menuId) : null;
|
|
const menuOpen = menu && !menu.classList.contains('hidden');
|
|
if (!menuOpen) {
|
|
header.removeAttribute('data-scrolled');
|
|
if (isTransparentBar) {
|
|
header.classList.remove(...BAR_SCROLLED_CLASSES);
|
|
header.classList.add('bg-transparent');
|
|
}
|
|
}
|
|
}
|
|
ticking = false;
|
|
});
|
|
}
|
|
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
|
|
// Set initial state
|
|
onScroll();
|
|
});
|
|
}
|
|
|
|
initScrollWatcher();
|
|
document.addEventListener('astro:page-load', initScrollWatcher);
|
|
document.addEventListener('astro:after-swap', initScrollWatcher);
|
|
</script>
|
|
|
|
<script>
|
|
function initScrollProgress() {
|
|
const bar = document.getElementById('scroll-progress-bar');
|
|
if (!bar) return;
|
|
if (bar.dataset.progressInit) return;
|
|
bar.dataset.progressInit = 'true';
|
|
|
|
let ticking = false;
|
|
|
|
function update() {
|
|
if (!bar) return;
|
|
const scrollTop = window.scrollY;
|
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
|
const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
|
bar!.style.width = `${pct}%`;
|
|
ticking = false;
|
|
}
|
|
|
|
window.addEventListener('scroll', () => {
|
|
if (!ticking) {
|
|
ticking = true;
|
|
requestAnimationFrame(update);
|
|
}
|
|
}, { passive: true });
|
|
|
|
update();
|
|
}
|
|
|
|
initScrollProgress();
|
|
document.addEventListener('astro:page-load', initScrollProgress);
|
|
document.addEventListener('astro:after-swap', initScrollProgress);
|
|
</script>
|
|
|
|
<style is:global>
|
|
.mobile-menu-blur {
|
|
opacity: 0.3;
|
|
filter: blur(4px);
|
|
transition: opacity 200ms, filter 200ms;
|
|
}
|
|
|
|
/* ===== Floating header: scroll state ===== */
|
|
[data-header-shape="floating"][data-scrolled] {
|
|
background: color-mix(in oklch, var(--color-background) 92%, transparent);
|
|
backdrop-filter: blur(24px);
|
|
border-color: var(--color-border);
|
|
box-shadow: 0 4px 20px -6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
/* ===== Floating header: color flip (invert → normal on scroll) ===== */
|
|
|
|
/* Text elements: on-invert → foreground */
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-text {
|
|
color: var(--color-on-invert);
|
|
transition: color 300ms;
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-text {
|
|
color: var(--color-foreground);
|
|
}
|
|
|
|
/* Logo text: on-invert → brand-500 */
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-logo-text {
|
|
color: var(--color-on-invert);
|
|
transition: color 300ms;
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-logo-text {
|
|
color: var(--color-brand-500);
|
|
}
|
|
|
|
/* Non-invert floating: use normal colors */
|
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-logo-text {
|
|
color: var(--color-brand-500);
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text {
|
|
color: var(--color-foreground-muted);
|
|
transition: color 300ms;
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text:hover {
|
|
color: var(--color-foreground);
|
|
}
|
|
|
|
/* ===== Floating nav link underline indicators ===== */
|
|
[data-header-shape="floating"] .nav-link::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 2px;
|
|
left: 50%;
|
|
right: 50%;
|
|
height: 2px;
|
|
background: currentColor;
|
|
border-radius: 1px;
|
|
transition: left 200ms, right 200ms;
|
|
}
|
|
|
|
[data-header-shape="floating"] .nav-link:hover::after,
|
|
[data-header-shape="floating"] .nav-link.hdr-nav-active::after {
|
|
left: 12px;
|
|
right: 12px;
|
|
}
|
|
|
|
/* ===== CTA: invert color flip ===== */
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta {
|
|
background: white;
|
|
color: #111;
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
transition: background 300ms, color 300ms, border-color 300ms;
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta:hover {
|
|
background: rgba(255, 255, 255, 0.9);
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta {
|
|
background: var(--color-primary);
|
|
color: var(--color-primary-foreground);
|
|
}
|
|
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
/* ===== Reduced motion ===== */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
[data-header-shape="floating"],
|
|
[data-header-shape="floating"] .hdr-invert-text,
|
|
[data-header-shape="floating"] .hdr-logo-text,
|
|
[data-header-shape="floating"] .hdr-invert-cta,
|
|
[data-header-shape="floating"] .nav-link::after {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
</style>
|