@@ -228,32 +229,5 @@ const hasLinks = pdfUrl || codeUrl || doiUrl;
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 5d7d23b9..48fae832 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -27,7 +27,7 @@ import Footer from '../components/Footer.astro';
*
* ```
*/
-interface Props extends HTMLAttributes<'html'> {
+export interface Props extends HTMLAttributes<'html'> {
/** Page title (will have " | Cris Benge" appended) */
title: string;
/** Meta description for SEO */
diff --git a/src/scripts/featured-project-card.ts b/src/scripts/featured-project-card.ts
new file mode 100644
index 00000000..b8a9d3b4
--- /dev/null
+++ b/src/scripts/featured-project-card.ts
@@ -0,0 +1,28 @@
+/**
+ * Featured project card GitHub link handling.
+ * Stops propagation on GitHub links to prevent card navigation.
+ */
+
+let initialized = false;
+
+function initFeaturedProjectCards(): void {
+ // Prevent multiple initializations
+ if (initialized) return;
+ initialized = true;
+
+ // Use event delegation for efficiency
+ document.addEventListener('click', event => {
+ const target = event.target as HTMLElement;
+ const githubLink = target.closest('[data-github-link]');
+ if (githubLink) {
+ event.stopPropagation();
+ }
+ });
+}
+
+// Initialize on DOMContentLoaded
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initFeaturedProjectCards);
+} else {
+ initFeaturedProjectCards();
+}
diff --git a/src/scripts/publication-card.ts b/src/scripts/publication-card.ts
new file mode 100644
index 00000000..7775bbfa
--- /dev/null
+++ b/src/scripts/publication-card.ts
@@ -0,0 +1,42 @@
+/**
+ * Publication card abstract toggle functionality.
+ * Progressive enhancement: abstracts visible by default, JS collapses them initially.
+ */
+
+function initPublicationCards(): void {
+ document
+ .querySelectorAll('[data-component="publication-card"]')
+ .forEach(card => {
+ const toggle = card.querySelector(
+ '[data-toggle]',
+ ) as HTMLButtonElement | null;
+ const abstract = card.querySelector(
+ '[data-abstract]',
+ ) as HTMLElement | null;
+ const toggleText = card.querySelector(
+ '[data-toggle-text]',
+ ) as HTMLElement | null;
+
+ if (!toggle || !abstract || !toggleText) return;
+
+ // Initially collapse abstracts (JS enhancement)
+ abstract.classList.remove('expanded');
+ toggle.setAttribute('aria-expanded', 'false');
+ toggleText.textContent = 'Show Abstract';
+
+ toggle.addEventListener('click', () => {
+ const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
+
+ toggle.setAttribute('aria-expanded', String(!isExpanded));
+ abstract.classList.toggle('expanded');
+ toggleText.textContent = isExpanded ? 'Show Abstract' : 'Hide Abstract';
+ });
+ });
+}
+
+// Initialize on DOMContentLoaded
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initPublicationCards);
+} else {
+ initPublicationCards();
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index 7f249b9e..7cf64f12 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -541,45 +541,14 @@
(--spacing-section, --spacing-card-gap) remain available for direct use. */
/* =============================================================================
- 8. LINE CLAMP UTILITIES - Text Truncation
+ 8. LINE CLAMP UTILITIES - Now Using Tailwind Built-ins
==============================================================================
- Multi-line text truncation using -webkit-line-clamp.
- Provides consistent truncation behavior across card components.
+ Line-clamp utilities (line-clamp-1, line-clamp-2, line-clamp-3) are now
+ provided by Tailwind CSS v4 out of the box. No custom CSS needed.
- Classes:
- - .line-clamp-1: Truncate to 1 line
- - .line-clamp-2: Truncate to 2 lines
- - .line-clamp-3: Truncate to 3 lines
-
- Usage:
- Long text that will be truncated after 2 lines...
-
- Browser Support:
- - All modern browsers (Chrome, Firefox, Safari, Edge)
- - Degrades gracefully to showing full text in unsupported browsers
+ Usage: Long text...
============================================================================= */
-.line-clamp-1 {
- display: -webkit-box;
- -webkit-line-clamp: 1;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.line-clamp-2 {
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.line-clamp-3 {
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
/* =============================================================================
9. CARD ANIMATION UTILITIES
==============================================================================
diff --git a/src/utils/project-categories.ts b/src/utils/project-categories.ts
index 025c0687..e514404a 100644
--- a/src/utils/project-categories.ts
+++ b/src/utils/project-categories.ts
@@ -6,6 +6,8 @@
* consistent category-to-variant mapping and display labels.
*/
+import type {CollectionEntry} from 'astro:content';
+
/**
* Project category type from content collection schema.
*/
@@ -61,15 +63,6 @@ export const CATEGORY_LABELS: Record = {
/**
* Project data shape from content collection.
* Used by FeaturedProjectCard and SecondaryProjectCard components.
+ * Derived from the projects collection schema for type safety.
*/
-export interface ProjectData {
- title: string;
- description: string;
- image: ImageMetadata;
- category: ProjectCategory;
- skills: string[];
- tools: string[];
- githubUrl?: string;
- achievement?: string;
- affiliation?: string;
-}
+export type ProjectData = CollectionEntry<'projects'>['data'];
diff --git a/test/fixtures/props/award.ts b/test/fixtures/props/award.ts
new file mode 100644
index 00000000..aea2afd6
--- /dev/null
+++ b/test/fixtures/props/award.ts
@@ -0,0 +1,75 @@
+/**
+ * Shared test fixtures for award-related components
+ */
+
+export interface MockImageMetadata {
+ src: string;
+ width: number;
+ height: number;
+ format: string;
+}
+
+export interface MockAward {
+ title: string;
+ year: number;
+ category: 'competition' | 'professional';
+ description: string;
+ placement?: string;
+ organization?: string;
+ logoImage?: MockImageMetadata;
+}
+
+export const mockLogoImage: MockImageMetadata = {
+ src: '/mock-logo.svg',
+ width: 64,
+ height: 64,
+ format: 'svg',
+};
+
+export function createMockAward(overrides: Partial = {}): MockAward {
+ return {
+ title: 'Test Award',
+ year: 2020,
+ category: 'competition',
+ description: 'Test award description.',
+ ...overrides,
+ };
+}
+
+export const mockAwards = {
+ competition: createMockAward({
+ category: 'competition',
+ placement: '1st Place',
+ organization: 'DrivenData',
+ }),
+ professional: createMockAward({
+ category: 'professional',
+ organization: 'Microsoft',
+ }),
+ withLogo: createMockAward({
+ category: 'professional',
+ organization: 'Microsoft',
+ logoImage: mockLogoImage,
+ }),
+ firstPlace: createMockAward({
+ category: 'competition',
+ placement: '1st Place',
+ }),
+ secondPlace: createMockAward({
+ category: 'competition',
+ placement: '2nd Place',
+ }),
+ thirdPlace: createMockAward({
+ category: 'competition',
+ placement: '3rd Place',
+ }),
+ topPercent: createMockAward({
+ category: 'competition',
+ placement: 'Top 12%',
+ }),
+ minimal: createMockAward({
+ placement: undefined,
+ organization: undefined,
+ logoImage: undefined,
+ }),
+};
diff --git a/test/fixtures/props/certification.ts b/test/fixtures/props/certification.ts
new file mode 100644
index 00000000..b4553909
--- /dev/null
+++ b/test/fixtures/props/certification.ts
@@ -0,0 +1,47 @@
+/**
+ * Shared test fixtures for certification-related components
+ */
+
+export interface MockCertification {
+ name: string;
+ issuer: string;
+ year: number;
+ category: 'cloud' | 'data' | 'database' | 'other';
+ verificationUrl?: string;
+}
+
+export function createMockCertification(
+ overrides: Partial = {},
+): MockCertification {
+ return {
+ name: 'Azure Certified Solution Architect Expert',
+ issuer: 'Microsoft',
+ year: 2023,
+ category: 'cloud',
+ ...overrides,
+ };
+}
+
+export const mockCertifications = {
+ cloud: createMockCertification({
+ category: 'cloud',
+ }),
+ data: createMockCertification({
+ name: 'Data Engineering Professional',
+ category: 'data',
+ }),
+ database: createMockCertification({
+ name: 'Database Administrator',
+ category: 'database',
+ }),
+ other: createMockCertification({
+ name: 'Project Management Professional',
+ category: 'other',
+ }),
+ withVerification: createMockCertification({
+ verificationUrl: 'https://example.com/verify',
+ }),
+ minimal: createMockCertification({
+ verificationUrl: undefined,
+ }),
+};
diff --git a/test/fixtures/props/index.ts b/test/fixtures/props/index.ts
index bc10845e..c253ccbe 100644
--- a/test/fixtures/props/index.ts
+++ b/test/fixtures/props/index.ts
@@ -5,3 +5,7 @@
export * from './project';
export * from './education';
+export * from './publication';
+export * from './patent';
+export * from './certification';
+export * from './award';
diff --git a/test/fixtures/props/patent.ts b/test/fixtures/props/patent.ts
new file mode 100644
index 00000000..4e61cf4c
--- /dev/null
+++ b/test/fixtures/props/patent.ts
@@ -0,0 +1,51 @@
+/**
+ * Shared test fixtures for patent-related components
+ */
+
+export interface MockPatent {
+ title: string;
+ patentNumber: string;
+ filingDate: Date;
+ grantDate?: Date;
+ status: 'filed' | 'pending' | 'granted';
+ url?: string;
+ description?: string;
+}
+
+export function createMockPatent(
+ overrides: Partial = {},
+): MockPatent {
+ return {
+ title: 'Test Patent Title',
+ patentNumber: 'US 10,123,456',
+ filingDate: new Date('2020-01-15T12:00:00Z'),
+ status: 'granted',
+ ...overrides,
+ };
+}
+
+export const mockPatents = {
+ granted: createMockPatent({
+ status: 'granted',
+ grantDate: new Date('2022-06-20T12:00:00Z'),
+ }),
+ pending: createMockPatent({
+ status: 'pending',
+ grantDate: undefined,
+ }),
+ filed: createMockPatent({
+ status: 'filed',
+ grantDate: undefined,
+ }),
+ withUrl: createMockPatent({
+ url: 'https://patents.google.com/patent/US10123456',
+ }),
+ withDescription: createMockPatent({
+ description: 'A method for efficiently processing data.',
+ }),
+ minimal: createMockPatent({
+ grantDate: undefined,
+ url: undefined,
+ description: undefined,
+ }),
+};
diff --git a/test/fixtures/props/publication.ts b/test/fixtures/props/publication.ts
new file mode 100644
index 00000000..6b9365b7
--- /dev/null
+++ b/test/fixtures/props/publication.ts
@@ -0,0 +1,45 @@
+/**
+ * Shared test fixtures for publication-related components
+ */
+
+export interface MockPublication {
+ title: string;
+ authors: string[];
+ venue: string;
+ year: number;
+ abstract?: string;
+ pdfUrl?: string;
+ codeUrl?: string;
+ doiUrl?: string;
+}
+
+export function createMockPublication(
+ overrides: Partial = {},
+): MockPublication {
+ return {
+ title: 'Test Paper Title',
+ authors: ['Author One', 'Author Two'],
+ venue: 'ICML 2023',
+ year: 2023,
+ abstract: 'Test abstract content for the publication.',
+ ...overrides,
+ };
+}
+
+export const mockPublications = {
+ basic: createMockPublication(),
+ withLinks: createMockPublication({
+ pdfUrl: 'https://example.com/paper.pdf',
+ codeUrl: 'https://github.com/example/repo',
+ doiUrl: 'https://doi.org/10.1234/example',
+ }),
+ withCris: createMockPublication({
+ authors: ['Cris Benge', 'Jane Doe'],
+ }),
+ minimal: createMockPublication({
+ abstract: undefined,
+ pdfUrl: undefined,
+ codeUrl: undefined,
+ doiUrl: undefined,
+ }),
+};