diff --git a/src/components/SecondaryProjectCard.test.ts b/src/components/SecondaryProjectCard.test.ts
index 583f79d6..05198684 100644
--- a/src/components/SecondaryProjectCard.test.ts
+++ b/src/components/SecondaryProjectCard.test.ts
@@ -151,13 +151,13 @@ describe('SecondaryProjectCard', () => {
expect(result).toContain('hover:-translate-y-1');
});
- it('has hover shadow effect (shadow-md for secondary)', async () => {
+ it('has hover shadow effect', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(SecondaryProjectCard, {
props: {project: mockProject, slug: 'secondary-test'},
});
expect(result).toContain('shadow-sm');
- expect(result).toContain('hover:shadow-md');
+ expect(result).toContain('hover:shadow-lg');
});
it('has active tap state', async () => {
diff --git a/src/pages/about.astro b/src/pages/about.astro
index 3812e266..61795c2b 100644
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -150,7 +150,6 @@ const hasAwards = sortedAwards.length > 0;
issuer={cert.data.issuer}
year={cert.data.year}
category={cert.data.category}
- badgeUrl={cert.data.badgeUrl}
verificationUrl={cert.data.verificationUrl}
data-reveal
/>
diff --git a/src/scripts/navigation.ts b/src/scripts/navigation.ts
new file mode 100644
index 00000000..813b7d2c
--- /dev/null
+++ b/src/scripts/navigation.ts
@@ -0,0 +1,211 @@
+/**
+ * Navigation Script - Scroll behavior and mobile menu
+ *
+ * USAGE: This script is imported by Navigation.astro and handles:
+ * - Scroll-based hide/reveal behavior
+ * - Mobile menu open/close with accessibility
+ * - Focus trap for mobile menu
+ * - Keyboard navigation (Escape to close)
+ *
+ * NOTE: This script is bundled with Navigation.astro and only loads
+ * on pages that include the Navigation component.
+ */
+
+/**
+ * Initialize navigation functionality.
+ * Sets up scroll behavior, mobile menu, and accessibility features.
+ */
+export function initNavigation(): void {
+ const nav = document.querySelector(
+ '[data-component="navigation"]',
+ ) as HTMLElement | null;
+ const menuToggle = document.querySelector(
+ '[data-component="mobile-menu-toggle"]',
+ ) as HTMLButtonElement | null;
+ const mobileMenu = document.getElementById('mobile-menu');
+ const mobileMenuPanel = mobileMenu?.querySelector(
+ '.mobile-menu-panel',
+ ) as HTMLElement | null;
+ const mobileMenuBackdrop = mobileMenu?.querySelector(
+ '.mobile-menu-backdrop',
+ ) as HTMLElement | null;
+
+ // ---------------------------------------------------------------------------
+ // Scroll-based hide/reveal behavior
+ // ---------------------------------------------------------------------------
+ let lastScroll = 0;
+ let ticking = false;
+ let isMenuOpen = false;
+
+ function handleScroll(): void {
+ if (!nav || isMenuOpen) {
+ ticking = false;
+ return;
+ }
+
+ const currentScroll = window.scrollY;
+
+ if (currentScroll < 50) {
+ // Always show nav at top of page
+ nav.classList.remove('nav-hidden');
+ } else if (currentScroll > lastScroll + 5) {
+ // Scrolling down - hide nav
+ nav.classList.add('nav-hidden');
+ } else if (currentScroll < lastScroll - 5) {
+ // Scrolling up - show nav
+ nav.classList.remove('nav-hidden');
+ }
+
+ lastScroll = currentScroll;
+ ticking = false;
+ }
+
+ window.addEventListener('scroll', () => {
+ if (!ticking) {
+ requestAnimationFrame(handleScroll);
+ ticking = true;
+ }
+ });
+
+ // ---------------------------------------------------------------------------
+ // Mobile menu functionality
+ // ---------------------------------------------------------------------------
+
+ function openMenu(): void {
+ if (!nav || !menuToggle || !mobileMenu) return;
+
+ isMenuOpen = true;
+ nav.classList.add('menu-open');
+ menuToggle.setAttribute('aria-expanded', 'true');
+ menuToggle.setAttribute('aria-label', 'Close navigation menu');
+ mobileMenu.setAttribute('aria-hidden', 'false');
+
+ // Enable tabbing to menu items
+ const menuFocusables = mobileMenuPanel?.querySelectorAll('a, button');
+ menuFocusables?.forEach(el => el.removeAttribute('tabindex'));
+
+ // Prevent body scroll when menu is open
+ document.body.style.overflow = 'hidden';
+
+ // Focus first menu link
+ const firstLink = mobileMenuPanel?.querySelector('a') as HTMLElement | null;
+ firstLink?.focus();
+ }
+
+ function closeMenu(): void {
+ if (!nav || !menuToggle || !mobileMenu) return;
+
+ isMenuOpen = false;
+ nav.classList.remove('menu-open');
+ menuToggle.setAttribute('aria-expanded', 'false');
+ menuToggle.setAttribute('aria-label', 'Open navigation menu');
+ mobileMenu.setAttribute('aria-hidden', 'true');
+
+ // Disable tabbing to menu items when closed (for accessibility)
+ const menuFocusables = mobileMenuPanel?.querySelectorAll('a, button');
+ menuFocusables?.forEach(el => el.setAttribute('tabindex', '-1'));
+
+ // Restore body scroll
+ document.body.style.overflow = '';
+
+ // Return focus to toggle button
+ menuToggle.focus();
+ }
+
+ function toggleMenu(): void {
+ if (isMenuOpen) {
+ closeMenu();
+ } else {
+ openMenu();
+ }
+ }
+
+ // Toggle button click handler
+ menuToggle?.addEventListener('click', toggleMenu);
+
+ // Close on backdrop click
+ mobileMenuBackdrop?.addEventListener('click', closeMenu);
+
+ // Close on Escape key
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && isMenuOpen) {
+ closeMenu();
+ }
+ });
+
+ // ---------------------------------------------------------------------------
+ // Focus trap for mobile menu
+ // ---------------------------------------------------------------------------
+
+ function trapFocus(e: KeyboardEvent): void {
+ if (!isMenuOpen || !mobileMenuPanel || e.key !== 'Tab') return;
+
+ const focusableElements = mobileMenuPanel.querySelectorAll(
+ 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])',
+ );
+
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0] as HTMLElement;
+ const lastElement = focusableElements[
+ focusableElements.length - 1
+ ] as HTMLElement;
+
+ // Include the toggle button in the focus trap
+ if (e.shiftKey) {
+ // Shift + Tab: going backwards
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ menuToggle?.focus();
+ } else if (document.activeElement === menuToggle) {
+ e.preventDefault();
+ lastElement.focus();
+ }
+ } else {
+ // Tab: going forwards
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ menuToggle?.focus();
+ } else if (document.activeElement === menuToggle) {
+ e.preventDefault();
+ firstElement.focus();
+ }
+ }
+ }
+
+ document.addEventListener('keydown', trapFocus);
+
+ // ---------------------------------------------------------------------------
+ // Close menu on navigation (when clicking a link)
+ // ---------------------------------------------------------------------------
+
+ const menuLinks = mobileMenuPanel?.querySelectorAll('a');
+ menuLinks?.forEach(link => {
+ link.addEventListener('click', () => {
+ // Small delay to allow navigation to start
+ setTimeout(closeMenu, 100);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Close menu on window resize (when switching to desktop)
+ // ---------------------------------------------------------------------------
+
+ let resizeTimeout: ReturnType
;
+
+ window.addEventListener('resize', () => {
+ clearTimeout(resizeTimeout);
+ resizeTimeout = setTimeout(() => {
+ if (window.innerWidth >= 768 && isMenuOpen) {
+ closeMenu();
+ }
+ }, 100);
+ });
+}
+
+// Auto-initialize when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => initNavigation());
+} else {
+ initNavigation();
+}
diff --git a/src/scripts/scroll-reveal.ts b/src/scripts/scroll-reveal.ts
index 8fc65ad0..64e434d1 100644
--- a/src/scripts/scroll-reveal.ts
+++ b/src/scripts/scroll-reveal.ts
@@ -2,6 +2,18 @@
* Scroll Reveal Animation Script
* Uses IntersectionObserver to trigger fade-in animations on elements with [data-reveal] attribute
*
+ * USAGE: Import this script in any page that uses [data-reveal] elements:
+ *
+ * ```astro
+ *
+ * ```
+ *
+ * NOTE: This script is intentionally NOT in BaseLayout to enable
+ * optimal code splitting. Only pages with [data-reveal] elements
+ * should import this script.
+ *
* Features:
* - Observes elements with [data-reveal] attribute
* - Adds 'revealed' class when element enters viewport (10% visible)
diff --git a/src/utils/badge.test.ts b/src/utils/badge.test.ts
index 9bb7aad9..3deaca69 100644
--- a/src/utils/badge.test.ts
+++ b/src/utils/badge.test.ts
@@ -2,8 +2,10 @@ import {describe, it, expect} from 'vitest';
import {
BADGE_BASE_CLASSES,
CERTIFICATION_BADGE_COLORS,
+ CERTIFICATION_CATEGORY_LABELS,
PATENT_STATUS_COLORS,
AWARD_CATEGORY_COLORS,
+ EDUCATION_HONOR_COLORS,
getPlacementBadgeColor,
} from './badge';
@@ -41,6 +43,24 @@ describe('badge utilities', () => {
});
});
+ describe('CERTIFICATION_CATEGORY_LABELS', () => {
+ it('provides cloud category label', () => {
+ expect(CERTIFICATION_CATEGORY_LABELS.cloud).toBe('Cloud');
+ });
+
+ it('provides data category label', () => {
+ expect(CERTIFICATION_CATEGORY_LABELS.data).toBe('Data');
+ });
+
+ it('provides database category label', () => {
+ expect(CERTIFICATION_CATEGORY_LABELS.database).toBe('Database');
+ });
+
+ it('provides other category label', () => {
+ expect(CERTIFICATION_CATEGORY_LABELS.other).toBe('Other');
+ });
+ });
+
describe('PATENT_STATUS_COLORS', () => {
it('provides granted status colors (green)', () => {
expect(PATENT_STATUS_COLORS.granted).toContain('bg-green-100');
@@ -70,6 +90,15 @@ describe('badge utilities', () => {
});
});
+ describe('EDUCATION_HONOR_COLORS', () => {
+ it('provides blue color scheme for academic distinctions', () => {
+ expect(EDUCATION_HONOR_COLORS).toContain('bg-blue-100');
+ expect(EDUCATION_HONOR_COLORS).toContain('text-blue-800');
+ expect(EDUCATION_HONOR_COLORS).toContain('dark:bg-blue-900');
+ expect(EDUCATION_HONOR_COLORS).toContain('dark:text-blue-200');
+ });
+ });
+
describe('getPlacementBadgeColor', () => {
it('returns gold for 1st place', () => {
expect(getPlacementBadgeColor('1st Place')).toContain('bg-amber-100');
diff --git a/src/utils/badge.ts b/src/utils/badge.ts
index 9f3ee919..b50a8fd3 100644
--- a/src/utils/badge.ts
+++ b/src/utils/badge.ts
@@ -59,6 +59,26 @@ export const AWARD_CATEGORY_COLORS = {
'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200',
} as const;
+/**
+ * Education honor badge colors.
+ * Blue color scheme for academic distinctions.
+ */
+export const EDUCATION_HONOR_COLORS =
+ 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200' as const;
+
+/**
+ * Display labels for certification categories.
+ */
+export const CERTIFICATION_CATEGORY_LABELS: Record<
+ 'cloud' | 'data' | 'database' | 'other',
+ string
+> = {
+ cloud: 'Cloud',
+ data: 'Data',
+ database: 'Database',
+ other: 'Other',
+} as const;
+
/**
* Determines placement badge style based on ranking text.
* Provides semantic color coding:
diff --git a/src/utils/card-styles.test.ts b/src/utils/card-styles.test.ts
index 26021063..72b3508d 100644
--- a/src/utils/card-styles.test.ts
+++ b/src/utils/card-styles.test.ts
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
import {
cardContainerClasses,
cardSurfaceContainerClasses,
+ interactiveCardClasses,
BULLET_SEPARATOR,
} from './card-styles';
@@ -89,4 +90,46 @@ describe('card-styles utilities', () => {
expect(BULLET_SEPARATOR).toBe('•');
});
});
+
+ describe('interactiveCardClasses', () => {
+ it('provides interactive card classes with focus-within support', () => {
+ const classes = interactiveCardClasses();
+ expect(classes).toContain('rounded-lg');
+ expect(classes).toContain('overflow-hidden');
+ expect(classes).toContain('bg-surface');
+ expect(classes).toContain('dark:bg-surface-dark');
+ expect(classes).toContain('shadow-sm');
+ expect(classes).toContain('hover:shadow-lg');
+ expect(classes).toContain('transition-all');
+ expect(classes).toContain('hover:-translate-y-1');
+ expect(classes).toContain('active:scale-[0.98]');
+ });
+
+ it('includes focus-within ring styles', () => {
+ const classes = interactiveCardClasses();
+ expect(classes).toContain('focus-within:ring-2');
+ expect(classes).toContain('focus-within:ring-accent');
+ expect(classes).toContain('dark:focus-within:ring-accent-dark');
+ expect(classes).toContain('focus-within:ring-offset-2');
+ expect(classes).toContain('focus-within:ring-offset-bg');
+ });
+
+ it('merges custom classes with interactive card', () => {
+ const classes = interactiveCardClasses('featured-card custom-class');
+ expect(classes).toContain('featured-card');
+ expect(classes).toContain('custom-class');
+ expect(classes).toContain('rounded-lg');
+ });
+
+ it('handles undefined className', () => {
+ const classes = interactiveCardClasses(undefined);
+ expect(classes).toContain('rounded-lg');
+ expect(classes).not.toContain('undefined');
+ });
+
+ it('handles empty string className', () => {
+ const classes = interactiveCardClasses('');
+ expect(classes).toContain('rounded-lg');
+ });
+ });
});
diff --git a/src/utils/card-styles.ts b/src/utils/card-styles.ts
index d5e9a960..d57a209d 100644
--- a/src/utils/card-styles.ts
+++ b/src/utils/card-styles.ts
@@ -88,3 +88,37 @@ export function cardSurfaceContainerClasses(className?: string): string {
* ```
*/
export const BULLET_SEPARATOR = '•' as const;
+
+/**
+ * Interactive card container for clickable project cards.
+ * Includes focus-within ring, active scale, and elevated hover effect.
+ * Uses surface tokens for semantic color theming.
+ *
+ * Use this for: Project cards, clickable items with focus-within support
+ *
+ * @param className - Optional additional classes to merge
+ * @returns Merged class string with interactive card styles
+ *
+ * @example
+ * ```astro
+ * ---
+ * import {interactiveCardClasses} from '../utils/card-styles';
+ * ---
+ *
+ * Card content with nested link
+ *
+ * ```
+ */
+export function interactiveCardClasses(className?: string): string {
+ return cn(
+ 'rounded-lg overflow-hidden',
+ 'bg-surface dark:bg-surface-dark',
+ 'shadow-sm hover:shadow-lg',
+ 'transition-all duration-150 ease-out',
+ 'hover:-translate-y-1',
+ 'active:scale-[0.98]',
+ 'focus-within:ring-2 focus-within:ring-accent dark:focus-within:ring-accent-dark',
+ 'focus-within:ring-offset-2 focus-within:ring-offset-bg dark:focus-within:ring-offset-bg-dark',
+ className,
+ );
+}
diff --git a/src/utils/id-utils.test.ts b/src/utils/id-utils.test.ts
new file mode 100644
index 00000000..3bc44110
--- /dev/null
+++ b/src/utils/id-utils.test.ts
@@ -0,0 +1,39 @@
+import {describe, it, expect} from 'vitest';
+import {toSlugId} from './id-utils';
+
+describe('id-utils', () => {
+ describe('toSlugId', () => {
+ it('converts text to lowercase slug', () => {
+ expect(toSlugId('My Publication Title')).toBe('my-publication-title');
+ });
+
+ it('replaces multiple spaces with single hyphens', () => {
+ expect(toSlugId('Title With Spaces')).toBe('title-with-spaces');
+ });
+
+ it('handles single word input', () => {
+ expect(toSlugId('Title')).toBe('title');
+ });
+
+ it('adds prefix when provided', () => {
+ expect(toSlugId('My Title', 'abstract')).toBe('abstract-my-title');
+ });
+
+ it('works with various prefixes', () => {
+ expect(toSlugId('Test Item', 'section')).toBe('section-test-item');
+ expect(toSlugId('Test Item', 'content')).toBe('content-test-item');
+ });
+
+ it('handles already lowercase text', () => {
+ expect(toSlugId('already lowercase')).toBe('already-lowercase');
+ });
+
+ it('handles mixed case text', () => {
+ expect(toSlugId('MiXeD CaSe TeXt')).toBe('mixed-case-text');
+ });
+
+ it('returns empty prefix when prefix is undefined', () => {
+ expect(toSlugId('test', undefined)).toBe('test');
+ });
+ });
+});
diff --git a/src/utils/id-utils.ts b/src/utils/id-utils.ts
new file mode 100644
index 00000000..f0296333
--- /dev/null
+++ b/src/utils/id-utils.ts
@@ -0,0 +1,25 @@
+/**
+ * Utility functions for generating URL-safe element IDs.
+ */
+
+/**
+ * Converts a string to a URL-safe slug for use as element IDs.
+ * Replaces whitespace with hyphens and converts to lowercase.
+ *
+ * @param text - The text to convert
+ * @param prefix - Optional prefix for the ID
+ * @returns URL-safe slug
+ *
+ * @example
+ * ```ts
+ * toSlugId('My Publication Title');
+ * // Returns: 'my-publication-title'
+ *
+ * toSlugId('My Publication Title', 'abstract');
+ * // Returns: 'abstract-my-publication-title'
+ * ```
+ */
+export function toSlugId(text: string, prefix?: string): string {
+ const slug = text.replace(/\s+/g, '-').toLowerCase();
+ return prefix ? `${prefix}-${slug}` : slug;
+}