Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions src/components/ExternalLink.astro
Original file line number Diff line number Diff line change
Expand Up @@ -59,31 +59,16 @@ import {cn} from '../utils/cn';
* ```
*/
export interface Props extends HTMLAttributes<'a'> {
/** External URL (required) */
href: string;

/** Visual style variant */
variant?: 'text' | 'button' | 'button-outlined' | 'card-action' | 'badge';

/** Size variant */
size?: 'xs' | 'sm' | 'base' | 'lg';

/** Accessible label - automatically appends "(opens in new tab)" */
/** Automatically appends "(opens in new tab)" */
ariaLabel: string;

/** Optional icon prefix */
prefixIcon?: 'pdf' | 'github' | 'external' | 'none';

/** Show arrow suffix (default: true) */
showArrow?: boolean;

/** Button color scheme (only for variant='button') */
/** Only applies to variant='button' */
colorScheme?: 'accent' | 'neutral';

/** Additional classes via cn() utility */
class?: string;

/** data-testid for testing */
'data-testid'?: string;
}

Expand Down
6 changes: 5 additions & 1 deletion src/components/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const {class: className, showImage = true, ...attrs} = Astro.props;

// Profile data from centralized source
const profile = HERO_PROFILE;

// Animation timing constants (matches CSS sequence)
const ANIMATION_BASE_DELAY = 500;
const ANIMATION_STAGGER = 150;
---

<section
Expand Down Expand Up @@ -103,7 +107,7 @@ const profile = HERO_PROFILE;
? 'bg-surface text-text dark:bg-surface-dark dark:text-text-dark'
: 'bg-accent/5 text-accent-hover dark:bg-accent-dark/15 dark:text-accent-dark',
]}
style={`animation-delay: ${500 + index * 150}ms`}
style={`animation-delay: ${ANIMATION_BASE_DELAY + index * ANIMATION_STAGGER}ms`}
data-testid={`hero-credential-${index}`}
>
{cred.label}
Expand Down
109 changes: 63 additions & 46 deletions src/components/ProjectImage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,9 @@ import {cn} from '../utils/cn';
* ```
*/
interface Props extends HTMLAttributes<'div'> {
/** Image metadata from content collection */
image?: ImageMetadata;
/** Alt text for the image */
alt: string;
/** Layout variant: 'featured' for 16:9 aspect ratio, 'thumbnail' for square */
variant: 'featured' | 'thumbnail';
/** Whether to add hover scale effect (requires parent with group class) */
hoverScale?: boolean;
}

Expand All @@ -41,14 +37,66 @@ const {
} = Astro.props;

const hasValidImage = !!image;
const imageSource = image || placeholderImage;

// Base classes for all images
const baseImageClasses = 'w-full h-full object-cover';
const hoverClasses = hoverScale
? 'transition-transform duration-300 group-hover:scale-105'
: '';

// Image configuration based on variant and image availability
type ImageConfig = {
src: ImageMetadata;
widths?: number[];
width?: number;
height?: number;
formats?: ('avif' | 'webp')[];
class: string;
};

function getImageConfig(
hasImage: boolean,
imageVariant: 'featured' | 'thumbnail',
imageSource: ImageMetadata
): ImageConfig {
if (hasImage && imageVariant === 'featured') {
return {
src: imageSource,
widths: [400, 800, 1200],
formats: ['avif', 'webp'],
class: cn(baseImageClasses, hoverClasses),
};
}

if (hasImage && imageVariant === 'thumbnail') {
return {
src: imageSource,
width: 96,
height: 96,
class: cn(baseImageClasses, hoverClasses),
};
}

if (imageVariant === 'featured') {
return {
src: placeholderImage,
width: 800,
height: 450,
class: baseImageClasses,
};
}

// thumbnail placeholder
return {
src: placeholderImage,
width: 96,
height: 96,
class: baseImageClasses,
};
}

const imageConfig = getImageConfig(hasValidImage, variant, image || placeholderImage);

// Container classes based on variant
const containerClasses =
variant === 'featured'
Expand All @@ -57,45 +105,14 @@ const containerClasses =
---

<div class={cn(containerClasses, className)} {...attrs}>
{
hasValidImage ? (
variant === 'featured' ? (
<Image
src={imageSource}
alt={alt}
widths={[400, 800, 1200]}
formats={['avif', 'webp']}
loading="lazy"
class={cn(baseImageClasses, hoverClasses)}
/>
) : (
<Image
src={imageSource}
alt={alt}
width={96}
height={96}
loading="lazy"
class={cn(baseImageClasses, hoverClasses)}
/>
)
) : variant === 'featured' ? (
<Image
src={placeholderImage}
alt={alt}
width={800}
height={450}
loading="lazy"
class={baseImageClasses}
/>
) : (
<Image
src={placeholderImage}
alt={alt}
width={96}
height={96}
loading="lazy"
class={baseImageClasses}
/>
)
}
<Image
src={imageConfig.src}
alt={alt}
widths={imageConfig.widths}
width={imageConfig.width}
height={imageConfig.height}
formats={imageConfig.formats}
loading="lazy"
class={imageConfig.class}
/>
</div>
13 changes: 3 additions & 10 deletions src/components/PublicationCard.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
import type {HTMLAttributes} from 'astro/types';
import {cardSurfaceContainerClasses} from '../utils/card-styles';
import {cardSurfaceContainerClasses, BULLET_SEPARATOR} from '../utils/card-styles';
import {toSlugId} from '../utils/id-utils';
import ExternalLink from './ExternalLink.astro';

Expand All @@ -26,21 +26,14 @@ import ExternalLink from './ExternalLink.astro';
* ```
*/
export interface Props extends HTMLAttributes<'article'> {
/** Publication title */
title: string;
/** List of authors */
authors: string[];
/** Conference, journal, or venue name */
venue: string;
/** Publication year */
year: number;
/** Abstract text (optional - toggle hidden if not provided) */
/** Toggle hidden if not provided */
abstract?: string;
/** URL to PDF document */
pdfUrl?: string;
/** URL to code repository */
codeUrl?: string;
/** DOI URL */
doiUrl?: string;
}

Expand Down Expand Up @@ -79,7 +72,7 @@ const hasLinks = pdfUrl || codeUrl || doiUrl;
class="flex items-center gap-2 text-sm text-text-secondary dark:text-text-secondary-dark mb-2"
>
<span class="font-medium">{year}</span>
<span aria-hidden="true">·</span>
<Fragment set:html={BULLET_SEPARATOR} />
<span>{venue}</span>
</div>

Expand Down
42 changes: 14 additions & 28 deletions src/components/SEO.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
---
import {PROFILE_DATA} from '../data/profile';

/**
* SEO component for managing page metadata, Open Graph, Twitter cards, and JSON-LD
* Handles all SEO-related meta tags in one place
Expand All @@ -17,17 +19,11 @@
* ```
*/
interface Props {
/** Page title (will have " | Cris Benge" appended) */
title: string;
/** Meta description for SEO (max ~155 characters recommended) */
description: string;
/** OG image path (defaults to /og-default.png) */
image?: string;
/** Canonical URL (defaults to current page URL) */
canonical?: string;
/** Prevent indexing (for draft pages) */
noIndex?: boolean;
/** Include JSON-LD Person schema (home page only) */
includePersonSchema?: boolean;
}

Expand All @@ -40,42 +36,32 @@ const {
includePersonSchema = false,
} = Astro.props;

// Site configuration
const siteUrl = import.meta.env.SITE || 'https://cbenge509.github.io';
const siteName = 'Cris Benge';
// Site configuration from centralized profile data
const siteUrl = import.meta.env.SITE || PROFILE_DATA.siteUrl;
const siteName = PROFILE_DATA.name;

// Computed values
const fullTitle = `${title} | ${siteName}`;
const ogImage = image.startsWith('http') ? image : `${siteUrl}${image}`;
// Use site config + pathname for canonical URL (not Astro.url.href which uses localhost in dev)
const canonicalUrl = canonical || `${siteUrl}${Astro.url.pathname}`;

// JSON-LD Person schema for the home page
// JSON-LD Person schema for the home page (built from centralized profile data)
const personSchema = {
'@context': 'https://schema.org',
'@type': 'Person',
name: 'Cris Benge',
jobTitle: 'Head of Federal Innovation',
name: PROFILE_DATA.name,
jobTitle: PROFILE_DATA.jobTitle,
worksFor: {
'@type': 'Organization',
name: 'Google',
name: PROFILE_DATA.employer,
},
url: siteUrl,
sameAs: [
'https://github.com/cbenge509',
'https://www.linkedin.com/in/crisbenge/',
// Google Scholar URL to be added when available
],
alumniOf: [
{
'@type': 'CollegeOrUniversity',
name: 'Columbia University',
},
{
'@type': 'CollegeOrUniversity',
name: 'UC Berkeley',
},
],
sameAs: PROFILE_DATA.socialProfiles,
alumniOf: PROFILE_DATA.education.map((edu) => ({
'@type': 'CollegeOrUniversity',
name: edu.name,
})),
};
---

Expand Down
15 changes: 6 additions & 9 deletions src/components/ThemeToggle.astro
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ const {class: className, ...attrs} = Astro.props;
const THEME_KEY = 'theme';
let memoryTheme: string | null = null; // Fallback for private browsing

function isStorageAvailable(): boolean {
// Cache localStorage availability (only check once)
const storageAvailable = (() => {
try {
const test = '__storage_test__';
localStorage.setItem(test, test);
Expand All @@ -69,22 +70,18 @@ const {class: className, ...attrs} = Astro.props;
} catch {
return false;
}
}
})();

function getTheme(): string | null {
if (isStorageAvailable()) {
if (storageAvailable) {
return localStorage.getItem(THEME_KEY);
}
return memoryTheme;
}

function setTheme(theme: string): void {
try {
if (isStorageAvailable()) {
localStorage.setItem(THEME_KEY, theme);
}
} catch {
// Silently fail - use memory fallback
if (storageAvailable) {
localStorage.setItem(THEME_KEY, theme);
}
memoryTheme = theme;
}
Expand Down
28 changes: 28 additions & 0 deletions src/data/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,31 @@ export const HERO_PROFILE = {
{label: 'TS/SCI w/ Polygraph', type: 'clearance'},
] satisfies Credential[],
} as const;

/**
* Extended profile data for about page and SEO.
* Single source of truth for biographical and schema.org data.
*/
export const PROFILE_DATA = {
name: 'Cris Benge',
jobTitle: 'Head of Federal Innovation',
employer: 'Google',
siteUrl: 'https://cbenge509.github.io',
twitterHandle: '@cbaborern',

bio: {
intro:
"I'm Cris Benge, Head of Federal Innovation at Google, where I lead AI/ML prototype initiatives that transform how U.S. federal agencies leverage cutting-edge technology to solve complex mission problems.",
experience:
'With over two decades of experience in software engineering, data science, and technical leadership, I specialize in building scalable machine learning systems and driving innovation at the intersection of technology and public service.',
passion:
"I'm passionate about mentoring the next generation of technologists and contributing to open-source projects and research that advance the field of artificial intelligence.",
},

socialProfiles: [
'https://github.com/cbenge509',
'https://www.linkedin.com/in/crisbenge/',
],

education: [{name: 'Columbia University'}, {name: 'UC Berkeley'}],
} as const;
9 changes: 9 additions & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import Footer from '../components/Footer.astro';
* Provides semantic HTML structure, SEO metadata, and accessibility features
* @slot default - Page content (rendered inside <main>)
* @slot head - Additional head content (rendered at end of <head>)
*
* NOTE: Pages using [data-reveal] elements must import scroll-reveal script:
* ```astro
* <script>
* import '../scripts/scroll-reveal';
* </script>
* ```
* This enables optimal code splitting - only pages that need animations load the script.
*
* @example
* ```astro
* <BaseLayout title="Projects" description="My portfolio projects">
Expand Down
Loading