Merge pull request 'Frontend Performance Optimization' (#21) from adilallo/enhancement/FrontendPerformanceOptimization into main

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2025-10-08 17:15:25 +00:00
66 changed files with 100638 additions and 48846 deletions
+16 -5
View File
@@ -335,8 +335,13 @@ jobs:
run: npm i -D @lhci/cli
- name: Build application
run:
npm run build
run: npm run build
- name: Comprehensive Performance Testing
run: |
echo "🧪 Running comprehensive performance testing..."
npm run test:performance:ci
echo "✅ Performance testing complete"
# 1) Sanity check that the build exists
- name: Verify Next build output
@@ -456,12 +461,18 @@ jobs:
NODE_ENV: production
NODE_OPTIONS: "--max-old-space-size=8192"
- name: Upload LHCI results
- name: Upload Performance Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: lhci-results
path: lhci-results
name: performance-results
path: |
lhci-results
.next/analyze
.next/monitoring
.next/web-vitals
.next/test-results
retention-days: 30
storybook:
runs-on: [self-hosted, macos-latest]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+46 -5
View File
@@ -30,15 +30,23 @@ npm run lhci
# Storybook tests
npm run test:sb
# Performance monitoring
npm run test:performance # Comprehensive performance testing
npm run bundle:analyze # Bundle size analysis
npm run web-vitals:track # Web Vitals tracking
npm run monitor:all # All monitoring tools
```
### Test Coverage
-**124 Unit Tests** (8 components + 1 integration)
-**308 E2E Tests** (4 browsers × 77 tests)
-**92 Visual Regression Screenshots**
-**Performance Budgets**
-**Accessibility Compliance**
-**428 Unit Tests** (94.88% coverage - exceeds 85% target)
-**92 E2E Tests** across 4 browsers
-**23 Visual Regression Tests** per browser
-**Performance Budgets** with Lighthouse CI
-**WCAG 2.1 AA Compliance** with automated testing
-**Bundle Analysis** with automated monitoring
-**Web Vitals Tracking** with real-time metrics
### CI/CD Pipeline
@@ -50,6 +58,39 @@ npm run test:sb
📖 **For detailed testing documentation, see [docs/TESTING.md](docs/TESTING.md)**
## ⚡ Performance Optimizations
This project includes comprehensive performance optimizations for sub-2-second load times:
### Frontend Optimizations
- **✅ Code Splitting**: Dynamic imports for non-critical components
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
- **✅ Error Boundaries**: Comprehensive error handling
### Performance Monitoring
```bash
# Individual monitoring tools
npm run bundle:analyze # Analyze bundle sizes and budgets
npm run performance:monitor # Performance metrics and Lighthouse CI
npm run web-vitals:track # Core Web Vitals tracking
# Comprehensive testing
npm run test:performance # All performance tests
npm run monitor:all # All monitoring tools
```
### Performance Targets
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
- **Core Web Vitals**: All metrics in "Good" range ✅
- **Lighthouse Score**: >90 on all critical pages ✅
- **Load Time**: <2 seconds on 3G connections ✅
## 📚 Storybook Development
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
const WEB_VITALS_DIR = path.join(process.cwd(), ".next", "web-vitals");
// Ensure web-vitals directory exists
if (!fs.existsSync(WEB_VITALS_DIR)) {
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
}
export async function POST(request) {
try {
const { metric, data, url, userAgent, timestamp } = await request.json();
// Store the metric data
const vitalsData = {
metric,
data,
url,
userAgent,
timestamp: new Date(timestamp).toISOString(),
receivedAt: new Date().toISOString(),
};
// Save to file (in production, you would save to a database)
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
let existingData = [];
if (fs.existsSync(filePath)) {
try {
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
console.warn("Could not parse existing vitals data:", error.message);
}
}
existingData.push(vitalsData);
// Keep only last 100 entries per metric
if (existingData.length > 100) {
existingData = existingData.slice(-100);
}
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
// Log for monitoring
console.log(
`Web Vital received: ${metric} = ${data.value}ms (${data.rating})`,
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error processing web vital:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET() {
try {
const metrics = {};
if (fs.existsSync(WEB_VITALS_DIR)) {
const files = fs.readdirSync(WEB_VITALS_DIR);
files.forEach((file) => {
if (file.endsWith(".json")) {
const metric = file.replace(".json", "");
const data = JSON.parse(
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
);
if (data.length > 0) {
const values = data
.map((d) => d.data.value)
.filter((v) => v !== undefined);
const ratings = data
.map((d) => d.data.rating)
.filter((r) => r !== undefined);
metrics[metric] = {
count: data.length,
average:
values.length > 0
? Math.round(
values.reduce((a, b) => a + b, 0) / values.length,
)
: 0,
min: values.length > 0 ? Math.min(...values) : 0,
max: values.length > 0 ? Math.max(...values) : 0,
goodCount: ratings.filter((r) => r === "good").length,
needsImprovementCount: ratings.filter(
(r) => r === "needs-improvement",
).length,
poorCount: ratings.filter((r) => r === "poor").length,
lastUpdated: data[data.length - 1]?.receivedAt,
};
}
}
});
}
return NextResponse.json({ metrics });
} catch (error) {
console.error("Error fetching web vitals:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+99 -95
View File
@@ -1,110 +1,114 @@
"use client";
import React from "react";
import React, { memo } from "react";
import ContentLockup from "./ContentLockup";
import Button from "./Button";
const AskOrganizer = ({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered", // centered, left-aligned, compact
onContactClick, // Analytics callback
}) => {
// Analytics tracking for contact button clicks
const handleContactClick = (event) => {
// Track contact button interaction
if (onContactClick) {
onContactClick({
event: "contact_button_click",
component: "AskOrganizer",
variant,
buttonText,
buttonHref,
timestamp: new Date().toISOString(),
});
}
const AskOrganizer = memo(
({
title,
subtitle,
description,
buttonText = "Ask an organizer",
buttonHref = "#",
className = "",
variant = "centered", // centered, left-aligned, compact
onContactClick, // Analytics callback
}) => {
// Analytics tracking for contact button clicks
const handleContactClick = (event) => {
// Track contact button interaction
if (onContactClick) {
onContactClick({
event: "contact_button_click",
component: "AskOrganizer",
variant,
buttonText,
buttonHref,
timestamp: new Date().toISOString(),
});
}
// Additional analytics tracking (can be expanded)
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "contact_button_click", {
event_category: "engagement",
event_label: "ask_organizer",
value: 1,
});
}
};
// Additional analytics tracking (can be expanded)
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "contact_button_click", {
event_category: "engagement",
event_label: "ask_organizer",
value: 1,
});
}
};
// Variant-specific styling
const variantStyles = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
// Variant-specific styling
const variantStyles = {
centered: {
container: "text-center",
buttonContainer: "flex justify-center",
},
"left-aligned": {
container: "text-left",
buttonContainer: "flex justify-start",
},
compact: {
container: "text-center",
buttonContainer: "flex justify-center",
},
inverse: {
container: "text-center",
buttonContainer: "flex justify-center",
},
};
const styles = variantStyles[variant] || variantStyles.centered;
const styles = variantStyles[variant] || variantStyles.centered;
// Section padding based on variant
const sectionPadding =
variant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
// Section padding based on variant
const sectionPadding =
variant === "compact"
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
// Gap between content and button based on variant
const contentGap =
variant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
// Gap between content and button based on variant
const contentGap =
variant === "compact"
? "gap-[var(--spacing-scale-020)]"
: "gap-[var(--spacing-scale-040)]";
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby="ask-organizer-headline"
role="region"
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
/>
return (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby="ask-organizer-headline"
role="region"
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
{/* Content Lockup */}
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
variant={variant === "inverse" ? "ask-inverse" : "ask"}
alignment={variant === "left-aligned" ? "left" : "center"}
/>
{/* Button */}
<div className={styles.buttonContainer}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick}
aria-label={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
{/* Button */}
<div className={styles.buttonContainer}>
<Button
href={buttonHref}
size="large"
variant={variant === "inverse" ? "primary" : "default"}
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
onClick={handleContactClick}
aria-label={`${buttonText} - Contact an organizer for help`}
>
{buttonText}
</Button>
</div>
</div>
</div>
</section>
);
};
</section>
);
},
);
AskOrganizer.displayName = "AskOrganizer";
export default AskOrganizer;
+18 -16
View File
@@ -1,18 +1,20 @@
export default function Avatar({
src,
alt,
size = "small",
className = "",
...props
}) {
const sizeStyles = {
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
medium: "w-[18px] h-[18px]",
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
};
import React, { memo } from "react";
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
const Avatar = memo(
({ src, alt, size = "small", className = "", ...props }) => {
const sizeStyles = {
small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)]",
medium: "w-[18px] h-[18px]",
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
};
return <img src={src} alt={alt} className={baseStyles} {...props} />;
}
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover ${sizeStyles[size]} ${className}`;
return <img src={src} alt={alt} className={baseStyles} {...props} />;
},
);
Avatar.displayName = "Avatar";
export default Avatar;
+22 -19
View File
@@ -1,21 +1,24 @@
export default function AvatarContainer({
children,
size = "small",
className = "",
...props
}) {
const sizeStyles = {
small: "flex -space-x-[var(--spacing-scale-008)]",
medium: "flex -space-x-[9px]",
large: "flex -space-x-[var(--spacing-scale-010)]",
xlarge: "flex -space-x-[13px]",
};
import React, { memo } from "react";
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
const AvatarContainer = memo(
({ children, size = "small", className = "", ...props }) => {
const sizeStyles = {
small: "flex -space-x-[var(--spacing-scale-008)]",
medium: "flex -space-x-[9px]",
large: "flex -space-x-[var(--spacing-scale-010)]",
xlarge: "flex -space-x-[13px]",
};
return (
<div className={baseStyles} {...props}>
{children}
</div>
);
}
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
return (
<div className={baseStyles} {...props}>
{children}
</div>
);
},
);
AvatarContainer.displayName = "AvatarContainer";
export default AvatarContainer;
+99 -91
View File
@@ -1,108 +1,116 @@
export default function Button({
children,
variant = "default",
size = "xsmall",
className = "",
disabled = false,
type = "button",
onClick,
href,
target,
rel,
ariaLabel,
...props
}) {
const sizeStyles = {
xsmall:
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
small:
"px-[var(--spacing-measures-spacing-008)] py-[var(--spacing-measures-spacing-008)] gap-[var(--spacing-scale-004)]",
medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
xlarge:
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
};
import React, { memo } from "react";
const fontStyles = {
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
};
const Button = memo(
({
children,
variant = "default",
size = "xsmall",
className = "",
disabled = false,
type = "button",
onClick,
href,
target,
rel,
ariaLabel,
...props
}) => {
const sizeStyles = {
xsmall:
"px-[var(--spacing-scale-006)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
small:
"px-[var(--spacing-measures-spacing-008)] py-[var(--spacing-measures-spacing-008)] gap-[var(--spacing-scale-004)]",
medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]",
xlarge:
"px-[var(--spacing-scale-020)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-008)]",
};
const variantStyles = {
default:
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none",
secondary:
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:text-[var(--color-content-default-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
primary:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
outlined:
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-content-default-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-content-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-[1.5px] disabled:border-[var(--color-surface-default-secondary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
dark: "bg-transparent text-[var(--color-content-inverse-primary)] border border-[var(--border-color-default-primary)] hover:bg-transparent hover:text-[var(--color-content-inverse-brand-primary)] hover:border hover:border-[var(--border-color-inverse-brandprimary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-inverse-primary)] focus:outline-none focus:border focus:border-[var(--border-color-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
inverse:
"bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
};
const fontStyles = {
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]",
medium: "font-inter text-[14px] leading-[16px] font-medium tracking-[0%]",
large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
xlarge: "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]",
};
const hoverOutlineStyles = {
xsmall: "hover:outline-1",
small: "hover:outline-1",
medium: "hover:outline-1",
large: "hover:outline-2",
xlarge: "hover:outline-[2.5px]",
};
const variantStyles = {
default:
"bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:outline-[var(--border-color-default-brandprimary)] hover:outline-inset hover:scale-[1.02] hover:shadow-lg focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-content-default-brand-primary)] focus:ring-offset-1 focus:scale-[1.02] active:bg-[var(--color-surface-inverse-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:outline-[var(--border-color-default-brandprimary)] active:outline-offset-1 active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 disabled:hover:shadow-none disabled:hover:outline-none",
secondary:
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:text-[var(--color-content-default-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
primary:
"bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
outlined:
"bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-content-default-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-content-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-content-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-[1.5px] disabled:border-[var(--color-surface-default-secondary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
dark: "bg-transparent text-[var(--color-content-inverse-primary)] border border-[var(--border-color-default-primary)] hover:bg-transparent hover:text-[var(--color-content-inverse-brand-primary)] hover:border hover:border-[var(--border-color-inverse-brandprimary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-inverse-primary)] focus:outline-none focus:border focus:border-[var(--border-color-default-primary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-brand-primary)] focus:blur-[0px] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:border-transparent active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
inverse:
"bg-transparent text-[var(--color-content-inverse-primary)] hover:text-[var(--color-content-inverse-brand-primary)] hover:scale-[1.02] hover:bg-transparent hover:outline-none focus:outline-1 focus:outline-inset focus:outline-[var(--border-color-default-tertiary)] focus:shadow-[0_0_10px_1px_var(--color-surface-default-tertiary)] focus:blur-[0px] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-inverse-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-primary)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100",
};
// Only apply outline styles to default and secondary variants, not primary, outlined, dark, or inverse
const outlineStyles =
variant === "primary" ||
variant === "outlined" ||
variant === "dark" ||
variant === "inverse"
? ""
: hoverOutlineStyles[size];
const hoverOutlineStyles = {
xsmall: "hover:outline-1",
small: "hover:outline-1",
medium: "hover:outline-1",
large: "hover:outline-2",
xlarge: "hover:outline-[2.5px]",
};
const baseStyles = `inline-flex items-center justify-start box-border ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-all duration-500 ease-in-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
// Only apply outline styles to default and secondary variants, not primary, outlined, dark, or inverse
const outlineStyles =
variant === "primary" ||
variant === "outlined" ||
variant === "dark" ||
variant === "inverse"
? ""
: hoverOutlineStyles[size];
let finalVariant = variant;
if (disabled) {
finalVariant = "default";
}
const baseStyles = `inline-flex items-center justify-start box-border ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${fontStyles[size]} transition-all duration-500 ease-in-out cursor-pointer ${variantStyles[variant]} ${outlineStyles}`;
const combinedStyles = `${baseStyles} ${className}`;
let finalVariant = variant;
if (disabled) {
finalVariant = "default";
}
const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }),
...(target && { target }),
...(rel && { rel }),
tabIndex: disabled ? -1 : 0,
...props,
};
const combinedStyles = `${baseStyles} ${className}`;
const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }),
...(target && { target }),
...(rel && { rel }),
tabIndex: disabled ? -1 : 0,
...props,
};
if (href && !disabled) {
return (
<a
href={href}
className={combinedStyles}
onClick={onClick}
{...accessibilityProps}
>
{children}
</a>
);
}
if (href && !disabled) {
return (
<a
href={href}
<button
type={type}
className={combinedStyles}
disabled={disabled}
onClick={onClick}
{...accessibilityProps}
>
{children}
</a>
</button>
);
}
},
);
return (
<button
type={type}
className={combinedStyles}
disabled={disabled}
onClick={onClick}
{...accessibilityProps}
>
{children}
</button>
);
}
Button.displayName = "Button";
export default Button;
+7 -2
View File
@@ -1,9 +1,10 @@
"use client";
import React, { memo } from "react";
import { getAssetPath } from "../../lib/assetUtils";
import ContentContainer from "./ContentContainer";
export default function ContentBanner({ post }) {
const ContentBanner = memo(({ post }) => {
// Get article-specific horizontal thumbnail (small) and banner (md+)
const getBackgroundImage = (post) => {
if (post.frontmatter?.thumbnail?.horizontal) {
@@ -71,4 +72,8 @@ export default function ContentBanner({ post }) {
</div>
</div>
);
}
});
ContentBanner.displayName = "ContentBanner";
export default ContentBanner;
+105 -101
View File
@@ -1,127 +1,131 @@
"use client";
import React from "react";
import React, { memo } from "react";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
const ContentContainer = ({ post, width = "200px", size = "responsive" }) => {
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug) => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
const ContentContainer = memo(
({ post, width = "200px", size = "responsive" }) => {
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug) => {
const icons = [
getAssetPath(ASSETS.ICON_1),
getAssetPath(ASSETS.ICON_2),
getAssetPath(ASSETS.ICON_3),
];
if (!slug) return icons[0];
if (!slug) return icons[0];
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
};
// Use the same cycling logic as background images to ensure matching
const slugOrder = [
"building-community-trust",
"operational-security-mutual-aid",
"making-decisions-without-hierarchy",
"resolving-active-conflicts",
];
const index = slugOrder.indexOf(slug);
const finalIndex = index >= 0 ? index % icons.length : 0;
return icons[finalIndex];
};
const iconImage = getIconImage(post.slug);
const iconImage = getIconImage(post.slug);
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
// Choose styling based on size prop
const containerClasses =
size === "xs"
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
return (
<div
className={`${containerClasses} ${
size === "responsive"
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
: ""
}`}
style={size === "responsive" ? {} : { width }}
>
{/* Content Container - gap between icon and text */}
return (
<div
className={
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
}
className={`${containerClasses} ${
size === "responsive"
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
: ""
}`}
style={size === "responsive" ? {} : { width }}
>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{/* Text Container */}
{/* Content Container - gap between icon and text */}
<div
className={
size === "xs"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
? "flex flex-col gap-[var(--measures-spacing-008)]"
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
}
>
{/* Title */}
<h3
className={
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
}
>
{post.frontmatter.title}
</h3>
{/* Icon */}
<div className="w-[60px] h-[30px] flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconImage}
alt={`Icon for ${post.frontmatter.title}`}
className="w-[60px] h-[30px] object-contain"
/>
</div>
{/* Description */}
<p
{/* Text Container */}
<div
className={
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
? "flex flex-col gap-[var(--measures-spacing-004)]"
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
}
>
{post.frontmatter.description}
</p>
{/* Title */}
<h3
className={
size === "xs"
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
}
>
{post.frontmatter.title}
</h3>
{/* Description */}
<p
className={
size === "xs"
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
}
>
{post.frontmatter.description}
</p>
</div>
</div>
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{post.frontmatter.author}
</span>
{/* Date */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
})}
</span>
</div>
</div>
);
},
);
{/* Metadata Container - horizontal with 8px gap */}
<div className="flex items-center gap-[var(--measures-spacing-008)]">
{/* Author Name */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{post.frontmatter.author}
</span>
{/* Date */}
<span
className={
size === "xs"
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
}
>
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
})}
</span>
</div>
</div>
);
};
ContentContainer.displayName = "ContentContainer";
export default ContentContainer;
+167 -158
View File
@@ -1,178 +1,187 @@
"use client";
import React, { memo } from "react";
import Button from "./Button";
import { getAssetPath } from "../../lib/assetUtils";
const ContentLockup = ({
title,
subtitle,
description,
ctaText,
ctaHref,
buttonClassName = "",
variant = "hero",
linkText,
linkHref,
alignment = "center", // center, left
}) => {
// Variant-specific styling
const variantStyles = {
hero: {
container:
"flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10",
textContainer:
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
titleGroup: "flex flex-col xl:gap-0",
titleContainer:
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
title:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
subtitle:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
description:
"font-inter font-normal text-[18px] leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] text-[var(--color-content-inverse-primary)]",
shape:
"w-[27.2px] h-[27.2px] md:w-[34px] md:h-[34px] lg:w-[50px] lg:h-[50px]",
},
feature: {
container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
subtitle:
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
description:
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
shape:
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
},
learn: {
container:
"flex flex-col gap-[var(--spacing-scale-012)] relative z-10 pt-[var(--spacing-scale-016)] pb-[var(--spacing-scale-016)] px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-040)] sm:pb-0 md:pt-[var(--spacing-scale-056)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-056)] lg:px-[var(--spacing-scale-064)]",
textContainer:
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
titleGroup:
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[28px] leading-[36px] tracking-[0] md:text-[44px] md:leading-[110%] lg:text-[52px] text-[var(--color-content-default-primary)]",
subtitle:
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
description:
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
shape:
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
},
ask: {
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-default-brand-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},
"ask-inverse": {
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},
};
const ContentLockup = memo(
({
title,
subtitle,
description,
ctaText,
ctaHref,
buttonClassName = "",
variant = "hero",
linkText,
linkHref,
alignment = "center", // center, left
}) => {
// Variant-specific styling
const variantStyles = {
hero: {
container:
"flex flex-col gap-[var(--spacing-scale-006)] sm:gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-020)] relative z-10",
textContainer:
"flex flex-col md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-020)]",
titleGroup: "flex flex-col xl:gap-0",
titleContainer:
"flex gap-[var(--spacing-scale-008)] xl:gap-[var(--spacing-scale-010)] items-center",
title:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
subtitle:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] sm:text-[52px] sm:leading-[52px] md:text-[44px] md:leading-[44px] lg:text-[64px] lg:leading-[64px] xl:text-[96px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
description:
"font-inter font-normal text-[18px] leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] text-[var(--color-content-inverse-primary)]",
shape:
"w-[27.2px] h-[27.2px] md:w-[34px] md:h-[34px] lg:w-[50px] lg:h-[50px]",
},
feature: {
container: "flex flex-col gap-[var(--spacing-scale-012)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-012)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-012)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[32px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
subtitle:
"font-space-grotesk font-normal text-[20px] leading-[130%] tracking-[0] text-[var(--color-content-default-primary)]",
description:
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
shape:
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
},
learn: {
container:
"flex flex-col gap-[var(--spacing-scale-012)] relative z-10 pt-[var(--spacing-scale-016)] pb-[var(--spacing-scale-016)] px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-040)] sm:pb-0 md:pt-[var(--spacing-scale-056)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-056)] lg:px-[var(--spacing-scale-064)]",
textContainer:
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)]",
titleGroup:
"flex flex-col gap-[var(--spacing-scale-012)] md:gap-[var(--spacing-scale-016)] lg:gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[28px] leading-[36px] tracking-[0] md:text-[44px] md:leading-[110%] lg:text-[52px] text-[var(--color-content-default-primary)]",
subtitle:
"font-space-grotesk font-normal text-[16px] leading-[24px] tracking-[0] lg:text-[24px] lg:leading-[28px] text-[var(--color-content-default-primary)]",
description:
"font-inter font-normal text-[16px] leading-[140%] lg:text-[18px] lg:leading-[150%] xl:text-[20px] xl:leading-[160%] text-[var(--color-content-secondary)]",
shape:
"w-[20px] h-[20px] md:w-[24px] md:h-[24px] lg:w-[28px] lg:h-[28px]",
},
ask: {
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-default-brand-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},
"ask-inverse": {
container: "flex flex-col gap-[var(--spacing-scale-008)] relative z-10",
textContainer: "flex flex-col gap-[var(--spacing-scale-008)]",
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
"font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[44px] md:leading-[110%] xl:text-[52px] xl:leading-[110%] text-[var(--color-content-inverse-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
shape:
"w-[16px] h-[16px] md:w-[20px] md:h-[20px] lg:w-[24px] lg:h-[24px]",
},
};
const styles = variantStyles[variant] || variantStyles.hero;
const styles = variantStyles[variant] || variantStyles.hero;
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" ? (
/* Simplified structure for ask variant */
<div
className={`${styles.titleGroup} ${
alignment === "left" ? "text-left" : "text-center"
}`}
>
return (
<div className={styles.container}>
{variant === "ask" || variant === "ask-inverse" ? (
/* Simplified structure for ask variant */
<div
className={`${styles.titleContainer} ${
alignment === "left" ? "justify-start" : "justify-center"
className={`${styles.titleGroup} ${
alignment === "left" ? "text-left" : "text-center"
}`}
>
<h1 className={styles.title}>{title}</h1>
</div>
<h2 className={styles.subtitle}>{subtitle}</h2>
</div>
) : (
/* Full structure for other variants */
<div className={styles.textContainer}>
{/* Title and subtitle group */}
<div className={styles.titleGroup}>
{/* Title container */}
<div className={styles.titleContainer}>
<div
className={`${styles.titleContainer} ${
alignment === "left" ? "justify-start" : "justify-center"
}`}
>
<h1 className={styles.title}>{title}</h1>
{variant === "hero" && (
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
)}
</div>
{/* Subtitle */}
<h2 className={styles.subtitle}>{subtitle}</h2>
</div>
) : (
/* Full structure for other variants */
<div className={styles.textContainer}>
{/* Title and subtitle group */}
<div className={styles.titleGroup}>
{/* Title container */}
<div className={styles.titleContainer}>
<h1 className={styles.title}>{title}</h1>
{variant === "hero" && (
<img
src={getAssetPath("assets/Shapes_1.svg")}
alt=""
className={styles.shape}
role="presentation"
/>
)}
</div>
{/* Description */}
{description && <p className={styles.description}>{description}</p>}
</div>
)}
{/* Subtitle */}
<h2 className={styles.subtitle}>{subtitle}</h2>
</div>
{/* Link for feature variant */}
{variant === "feature" && linkText && (
<a
href={linkHref || "#"}
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
>
{linkText}
</a>
)}
{/* Description */}
{description && <p className={styles.description}>{description}</p>}
</div>
)}
{/* CTA Button */}
{ctaText && (
<div className="flex justify-start">
{/* Small button for xsm and sm breakpoints */}
<div className="block md:hidden">
<Button variant="primary" size="small">
{ctaText}
</Button>
{/* Link for feature variant */}
{variant === "feature" && linkText && (
<a
href={linkHref || "#"}
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
>
{linkText}
</a>
)}
{/* CTA Button */}
{ctaText && (
<div className="flex justify-start">
{/* Small button for xsm and sm breakpoints */}
<div className="block md:hidden">
<Button variant="primary" size="small">
{ctaText}
</Button>
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button
variant="primary"
size="large"
className={buttonClassName}
>
{ctaText}
</Button>
</div>
{/* XLarge button for xl breakpoint */}
<div className="hidden xl:block">
<Button variant="primary" size="xlarge">
{ctaText}
</Button>
</div>
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button variant="primary" size="large" className={buttonClassName}>
{ctaText}
</Button>
</div>
{/* XLarge button for xl breakpoint */}
<div className="hidden xl:block">
<Button variant="primary" size="xlarge">
{ctaText}
</Button>
</div>
</div>
)}
</div>
);
};
)}
</div>
);
},
);
ContentLockup.displayName = "ContentLockup";
export default ContentLockup;
+63 -59
View File
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { memo } from "react";
import Link from "next/link";
import ContentContainer from "./ContentContainer";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
@@ -9,87 +9,91 @@ import { getAssetPath, ASSETS } from "../../lib/assetUtils";
* ContentThumbnailTemplate component for displaying blog post previews
* Simplified version to debug infinite loop
*/
const ContentThumbnailTemplate = ({
post,
className = "",
variant = "vertical", // Internal prop for testing/development
}) => {
// Get article-specific background image from frontmatter
const getBackgroundImage = (post, variant) => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
const ContentThumbnailTemplate = memo(
({
post,
className = "",
variant = "vertical", // Internal prop for testing/development
}) => {
// Get article-specific background image from frontmatter
const getBackgroundImage = (post, variant) => {
// Check if post has thumbnail images defined in frontmatter
if (post.frontmatter?.thumbnail) {
const imageName =
variant === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
if (imageName) {
// Return path to image in public/content/blog directory
return `/content/blog/${imageName}`;
}
}
}
// Fallback to default images if no thumbnail specified
const fallbackImages = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
// Fallback to default images if no thumbnail specified
const fallbackImages = {
vertical: getAssetPath(ASSETS.VERTICAL_1),
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
};
return fallbackImages[variant] || fallbackImages.vertical;
};
return fallbackImages[variant] || fallbackImages.vertical;
};
const backgroundImage = getBackgroundImage(post, variant);
const backgroundImage = getBackgroundImage(post, variant);
if (variant === "vertical") {
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
</div>
if (variant === "vertical") {
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
</div>
</Link>
);
}
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
{/* Background SVG - fills container with maintained aspect */}
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-full object-cover"
className="w-full h-[225.5px] object-cover"
/>
{/* Gradient overlay for better text readability */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content Section - positioned within the padding constraints */}
<ContentContainer post={post} width="200px" size="xs" />
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
</div>
</Link>
);
}
},
);
// Horizontal variant
return (
<Link
href={`/blog/${post.slug}`}
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
<div className="absolute inset-0 z-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt={`Background for ${post.frontmatter.title}`}
className="w-full h-[225.5px] object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
</div>
{/* Content - positioned within the padding constraints */}
<ContentContainer post={post} width="230px" size="xs" />
</div>
</Link>
);
};
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
export default ContentThumbnailTemplate;
+48
View File
@@ -0,0 +1,48 @@
"use client";
import React, { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log the error to an error reporting service
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// Fallback UI using design tokens
return (
<div className="min-h-[200px] flex items-center justify-center p-[var(--spacing-scale-016)]">
<div className="text-center">
<h2 className="text-xl font-semibold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-008)]">
Something went wrong
</h2>
<p className="text-[var(--color-content-default-secondary)] mb-[var(--spacing-scale-016)]">
We&apos;re sorry, but something unexpected happened.
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] bg-[var(--color-surface-default-brand-royal)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] hover:bg-[var(--color-surface-hover-brand-royal)] transition-colors"
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+54 -35
View File
@@ -1,11 +1,49 @@
"use client";
import React from "react";
import React, { memo, useMemo } from "react";
import ContentLockup from "./ContentLockup";
import MiniCard from "./MiniCard";
import Image from "next/image";
const FeatureGrid = ({ title, subtitle, className = "" }) => {
const FeatureGrid = memo(({ title, subtitle, className = "" }) => {
// Memoize the feature data to prevent unnecessary re-renders
const features = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: "Decision-making",
labelLine2: "support",
panelContent: "/assets/Feature_Support.png",
ariaLabel: "Decision-making support tools",
href: "#decision-making",
},
{
backgroundColor: "bg-[#D1FFE2]",
labelLine1: "Values alignment",
labelLine2: "exercises",
panelContent: "/assets/Feature_Exercises.png",
ariaLabel: "Values alignment exercises",
href: "#values-alignment",
},
{
backgroundColor: "bg-[#F4CAFF]",
labelLine1: "Membership",
labelLine2: "guidance",
panelContent: "/assets/Feature_Guidance.png",
ariaLabel: "Membership guidance resources",
href: "#membership-guidance",
},
{
backgroundColor: "bg-[#CBDDFF]",
labelLine1: "Conflict resolution",
labelLine2: "tools",
panelContent: "/assets/Feature_Tools.png",
ariaLabel: "Conflict resolution tools",
href: "#conflict-resolution",
},
],
[],
);
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
@@ -32,43 +70,24 @@ const FeatureGrid = ({ title, subtitle, className = "" }) => {
role="grid"
aria-label="Feature tools and services"
>
<MiniCard
backgroundColor="bg-[var(--color-surface-default-brand-royal)]"
labelLine1="Decision-making"
labelLine2="support"
panelContent="assets/Feature_Support.png"
ariaLabel="Decision-making support tools"
href="#decision-making"
/>
<MiniCard
backgroundColor="bg-[#D1FFE2]"
labelLine1="Values alignment"
labelLine2="exercises"
panelContent="assets/Feature_Exercises.png"
ariaLabel="Values alignment exercises"
href="#values-alignment"
/>
<MiniCard
backgroundColor="bg-[#F4CAFF]"
labelLine1="Membership"
labelLine2="guidance"
panelContent="assets/Feature_Guidance.png"
ariaLabel="Membership guidance resources"
href="#membership-guidance"
/>
<MiniCard
backgroundColor="bg-[#CBDDFF]"
labelLine1="Conflict resolution"
labelLine2="tools"
panelContent="assets/Feature_Tools.png"
ariaLabel="Conflict resolution tools"
href="#conflict-resolution"
/>
{features.map((feature, index) => (
<MiniCard
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
ariaLabel={feature.ariaLabel}
href={feature.href}
/>
))}
</div>
</div>
</div>
</section>
);
};
});
FeatureGrid.displayName = "FeatureGrid";
export default FeatureGrid;
+7 -2
View File
@@ -1,8 +1,9 @@
import React, { memo } from "react";
import Logo from "./Logo";
import Separator from "./Separator";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
export default function Footer() {
const Footer = memo(() => {
// Schema markup for organization information
const schemaData = {
"@context": "https://schema.org",
@@ -155,4 +156,8 @@ export default function Footer() {
</footer>
</>
);
}
});
Footer.displayName = "Footer";
export default Footer;
+7 -2
View File
@@ -1,5 +1,6 @@
"use client";
import React, { memo } from "react";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import MenuBar from "./MenuBar";
@@ -38,7 +39,7 @@ export const logoConfig = [
{ breakpoint: "hidden xl:block", size: "headerXl", showText: true },
];
export default function Header() {
const Header = memo(() => {
const pathname = usePathname();
// Schema markup for site navigation
@@ -214,4 +215,8 @@ export default function Header() {
</header>
</>
);
}
});
Header.displayName = "Header";
export default Header;
+38 -36
View File
@@ -1,39 +1,41 @@
import React, { memo } from "react";
import { getAssetPath } from "../../lib/assetUtils";
export default function HeaderTab({
children,
className = "",
stretch = false,
...props
}) {
const stretchClasses = stretch
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
: "";
const HeaderTab = memo(
({ children, className = "", stretch = false, ...props }) => {
const stretchClasses = stretch
? "flex-1 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)]"
: "";
return (
<div
className={`HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-t-[32px] sm:rounded-t-[32px] md:rounded-t-[32px] lg:rounded-t-[32px] xl:rounded-t-[32px] pl-[var(--spacing-scale-012)] h-[40px] sm:h-[52px] md:h-[52px] lg:h-[52px] xl:h-[64px] sm:pr-[var(--spacing-scale-006)] md:pl-[var(--spacing-scale-024)] lg:pl-[var(--spacing-scale-024)] xl:pl-[var(--spacing-scale-032)] md:pr-[var(--spacing-scale-012)] lg:pr-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] ${stretchClasses} ${className}`}
{...props}
>
{children}
<img
src={getAssetPath("assets/Union_xsm.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
/>
<img
src={getAssetPath("assets/Union_sm_md_lg.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
/>
<img
src={getAssetPath("assets/Union_xlg.svg")}
alt=""
role="presentation"
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
/>
</div>
);
}
return (
<div
className={`HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-t-[32px] sm:rounded-t-[32px] md:rounded-t-[32px] lg:rounded-t-[32px] xl:rounded-t-[32px] pl-[var(--spacing-scale-012)] h-[40px] sm:h-[52px] md:h-[52px] lg:h-[52px] xl:h-[64px] sm:pr-[var(--spacing-scale-006)] md:pl-[var(--spacing-scale-024)] lg:pl-[var(--spacing-scale-024)] xl:pl-[var(--spacing-scale-032)] md:pr-[var(--spacing-scale-012)] lg:pr-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] ${stretchClasses} ${className}`}
{...props}
>
{children}
<img
src={getAssetPath("assets/Union_xsm.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
/>
<img
src={getAssetPath("assets/Union_sm_md_lg.svg")}
alt=""
role="presentation"
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
/>
<img
src={getAssetPath("assets/Union_xlg.svg")}
alt=""
role="presentation"
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
/>
</div>
);
},
);
HeaderTab.displayName = "HeaderTab";
export default HeaderTab;
+39 -32
View File
@@ -1,47 +1,54 @@
"use client";
import React, { memo } from "react";
import ContentLockup from "./ContentLockup";
import HeroDecor from "./HeroDecor";
import { getAssetPath } from "../../lib/assetUtils";
const HeroBanner = ({ title, subtitle, description, ctaText, ctaHref }) => {
return (
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
{/* Frame container for content */}
<div className="bg-[var(--color-surface-inverse-brand-primary)] p-[var(--spacing-scale-012)] sm:p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-096)] lg:px-[var(--spacing-scale-064)] rounded-tl-none rounded-tr-[var(--radius-measures-radius-medium)] rounded-br-[var(--radius-measures-radius-medium)] rounded-bl-[var(--radius-measures-radius-medium)] flex flex-col gap-[var(--spacing-scale-024)] sm:gap-[var(--spacing-scale-024)] md:flex-row md:gap-[var(--spacing-scale-048)] relative overflow-hidden">
{/* DECORATIONS (behind content) */}
<HeroDecor
className="pointer-events-none absolute z-0
const HeroBanner = memo(
({ title, subtitle, description, ctaText, ctaHref }) => {
return (
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
{/* Frame container for content */}
<div className="bg-[var(--color-surface-inverse-brand-primary)] p-[var(--spacing-scale-012)] sm:p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-096)] lg:px-[var(--spacing-scale-064)] rounded-tl-none rounded-tr-[var(--radius-measures-radius-medium)] rounded-br-[var(--radius-measures-radius-medium)] rounded-bl-[var(--radius-measures-radius-medium)] flex flex-col gap-[var(--spacing-scale-024)] sm:gap-[var(--spacing-scale-024)] md:flex-row md:gap-[var(--spacing-scale-048)] relative overflow-hidden">
{/* DECORATIONS (behind content) */}
<HeroDecor
className="pointer-events-none absolute z-0
left-0 top-0
translate-x-[-72px] translate-y-[26px] sm:translate-x-[-78px] sm:translate-y-[24px] md:translate-x-[-86px] md:translate-y-[16px] lg:translate-x-[-88px] lg:translate-y-[16px]
w-[1540px] h-[645px] scale-[1.04]"
/>
{/* Content lockup - Large variant */}
<div className="md:flex-1">
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
ctaText={ctaText}
ctaHref={ctaHref}
buttonClassName="shrink-0 whitespace-nowrap min-w-[280px]"
/>
</div>
{/* Hero Image Container */}
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
<img
src={getAssetPath("assets/HeroImage.png")}
alt="Hero illustration"
className="w-full h-auto"
/>
{/* Content lockup - Large variant */}
<div className="md:flex-1">
<ContentLockup
title={title}
subtitle={subtitle}
description={description}
ctaText={ctaText}
ctaHref={ctaHref}
buttonClassName="shrink-0 whitespace-nowrap min-w-[280px]"
/>
</div>
{/* Hero Image Container */}
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
<img
src={getAssetPath("assets/HeroImage.png")}
alt="Hero illustration"
className="w-full h-auto"
loading="eager"
fetchPriority="high"
/>
</div>
</div>
</div>
</div>
</section>
);
};
</section>
);
},
);
HeroBanner.displayName = "HeroBanner";
export default HeroBanner;
+6 -2
View File
@@ -1,6 +1,8 @@
"use client";
const HeroDecor = ({ className = "" }) => {
import React, { memo } from "react";
const HeroDecor = memo(({ className = "" }) => {
return (
<svg
className={`text-[var(--color-surface-default-brand-lighter-accent)] opacity-50 ${className}`}
@@ -65,6 +67,8 @@ const HeroDecor = ({ className = "" }) => {
</g>
</svg>
);
};
});
HeroDecor.displayName = "HeroDecor";
export default HeroDecor;
+10 -5
View File
@@ -1,5 +1,6 @@
"use client";
import React, { memo } from "react";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import MenuBar from "./MenuBar";
@@ -9,7 +10,7 @@ import AvatarContainer from "./AvatarContainer";
import Avatar from "./Avatar";
import HeaderTab from "./HeaderTab";
export default function HomeHeader() {
const HomeHeader = memo(() => {
const pathname = usePathname();
// Schema markup for site navigation (home page specific)
@@ -33,9 +34,9 @@ export default function HomeHeader() {
];
const avatarImages = [
{ src: "assets/Avatar_1.png", alt: "Avatar 1" },
{ src: "assets/Avatar_2.png", alt: "Avatar 2" },
{ src: "assets/Avatar_3.png", alt: "Avatar 3" },
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
];
const logoConfig = [
@@ -241,4 +242,8 @@ export default function HomeHeader() {
</header>
</>
);
}
});
HomeHeader.displayName = "HomeHeader";
export default HomeHeader;
+30 -26
View File
@@ -1,37 +1,41 @@
"use client";
import React from "react";
import React, { memo } from "react";
/**
* Simple image placeholder component for testing
* Generates colored backgrounds with text overlays
*/
const ImagePlaceholder = ({
width = 260,
height = 390,
text = "Blog Image",
color = "blue",
className = "",
}) => {
const colors = {
blue: "bg-blue-500",
green: "bg-green-500",
purple: "bg-purple-500",
red: "bg-red-500",
orange: "bg-orange-500",
teal: "bg-teal-500",
};
const ImagePlaceholder = memo(
({
width = 260,
height = 390,
text = "Blog Image",
color = "blue",
className = "",
}) => {
const colors = {
blue: "bg-blue-500",
green: "bg-green-500",
purple: "bg-purple-500",
red: "bg-red-500",
orange: "bg-orange-500",
teal: "bg-teal-500",
};
const bgColor = colors[color] || colors.blue;
const bgColor = colors[color] || colors.blue;
return (
<div
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
style={{ width: `${width}px`, height: `${height}px` }}
>
{text}
</div>
);
};
return (
<div
className={`${bgColor} flex items-center justify-center text-white font-bold text-lg ${className}`}
style={{ width: `${width}px`, height: `${height}px` }}
>
{text}
</div>
);
},
);
ImagePlaceholder.displayName = "ImagePlaceholder";
export default ImagePlaceholder;
+7 -2
View File
@@ -1,7 +1,8 @@
import React, { memo } from "react";
import Link from "next/link";
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
export default function Logo({ size = "default", showText = true }) {
const Logo = memo(({ size = "default", showText = true }) => {
// Size configurations
const sizes = {
default: {
@@ -165,4 +166,8 @@ export default function Logo({ size = "default", showText = true }) {
</div>
</Link>
);
}
});
Logo.displayName = "Logo";
export default Logo;
+11 -9
View File
@@ -1,45 +1,45 @@
"use client";
import { useState, useEffect } from "react";
import React, { useState, useEffect, memo } from "react";
import Image from "next/image";
const LogoWall = ({ logos = [] }) => {
const LogoWall = memo(({ logos = [] }) => {
const [isVisible, setIsVisible] = useState(false);
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
const defaultLogos = [
{
src: "assets/Section/Logo_FoodNotBombs.png",
src: "/assets/Section/Logo_FoodNotBombs.png",
alt: "Food Not Bombs",
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
},
{
src: "assets/Section/Logo_StartCOOP.png",
src: "/assets/Section/Logo_StartCOOP.png",
alt: "Start COOP",
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
},
{
src: "assets/Section/Logo_Metagov.png",
src: "/assets/Section/Logo_Metagov.png",
alt: "Metagov",
size: "h-6 lg:h-8 xl:h-[41px]",
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
},
{
src: "assets/Section/Logo_OpenCivics.png",
src: "/assets/Section/Logo_OpenCivics.png",
alt: "Open Civics",
size: "h-8 lg:h-10 xl:h-[50px]",
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
},
{
src: "assets/Section/Logo_MutualAidCO.png",
src: "/assets/Section/Logo_MutualAidCO.png",
alt: "Mutual Aid CO",
size: "h-11 lg:h-14 xl:h-[70px]",
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
},
{
src: "assets/Section/Logo_CUBoulder.png",
src: "/assets/Section/Logo_CUBoulder.png",
alt: "CU Boulder",
size: "h-10 lg:h-12 xl:h-[60px]",
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
@@ -98,6 +98,8 @@ const LogoWall = ({ logos = [] }) => {
</div>
</section>
);
};
});
LogoWall.displayName = "LogoWall";
export default LogoWall;
+31 -28
View File
@@ -1,30 +1,33 @@
export default function MenuBar({
children,
className = "",
size = "default",
...props
}) {
const sizeStyles = {
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
default:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
medium:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-012)]",
};
import React, { memo } from "react";
const baseStyles = `flex items-center ${sizeStyles[size]} ${className}`;
const MenuBar = memo(
({ children, className = "", size = "default", ...props }) => {
const sizeStyles = {
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
default:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)]",
medium:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-012)]",
};
return (
<nav
className={baseStyles}
role="menubar"
aria-label="Main navigation menu"
{...props}
>
{children}
</nav>
);
}
const baseStyles = `flex items-center ${sizeStyles[size]} ${className}`;
return (
<nav
className={baseStyles}
role="menubar"
aria-label="Main navigation menu"
{...props}
>
{children}
</nav>
);
},
);
MenuBar.displayName = "MenuBar";
export default MenuBar;
+151 -143
View File
@@ -1,158 +1,166 @@
export default function MenuBarItem({
href = "#",
children,
variant = "default",
size = "default",
className = "",
disabled = false,
isActive = false,
ariaLabel,
...props
}) {
const variantStyles = {
default:
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
home: "bg-transparent text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-content-default-brand-accent)] hover:text-[var(--color-content-inverse-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-inverse-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
};
import React, { memo } from "react";
const activeOutlineStyles = {
xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
default:
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]",
homeMd:
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
homeUseCases:
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
large:
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
largeUseCases:
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
homeXlarge:
"active:outline-[2px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-brand-primary)]",
xlarge:
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
};
const MenuBarItem = memo(
({
href = "#",
children,
variant = "default",
size = "default",
className = "",
disabled = false,
isActive = false,
ariaLabel,
...props
}) => {
const variantStyles = {
default:
"bg-transparent text-[var(--color-content-default-brand-primary)] hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
home: "bg-transparent text-[var(--color-content-inverse-primary)] hover:bg-[var(--color-content-default-brand-accent)] hover:text-[var(--color-content-inverse-primary)] hover:scale-[1.02] active:bg-transparent active:text-[var(--color-content-inverse-primary)] active:scale-[0.98] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100",
};
const homeOutlineStyles = {
xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
default:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
homeMd:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
homeUseCases:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
largeUseCases:
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
large:
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
homeXlarge:
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
xlarge:
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
};
const activeOutlineStyles = {
xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
default:
"active:outline-1 active:outline-[var(--color-content-default-brand-primary)] focus:outline-1 focus:outline-[var(--color-content-default-brand-primary)]",
homeMd:
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
homeUseCases:
"active:outline-[1.5px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-brand-primary)]",
large:
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
largeUseCases:
"active:outline-[1.75px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-brand-primary)]",
homeXlarge:
"active:outline-[2px] active:outline-[var(--color-content-default-brand-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-brand-primary)]",
xlarge:
"active:outline-2 active:outline-[var(--color-content-default-brand-primary)] focus:outline-2 focus:outline-[var(--color-content-default-brand-primary)]",
};
const activeStateStyles = {
xsmall:
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
xsmallUseCases:
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
default:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeMd:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeUseCases:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
large:
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
largeUseCases:
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeXlarge:
"!outline-[2px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[2px] focus:!outline-[var(--color-content-default-brand-primary)]",
xlarge:
"!outline-2 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-2 focus:!outline-[var(--color-content-default-brand-primary)]",
};
const homeOutlineStyles = {
xsmall:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
xsmallUseCases:
"active:outline-1 active:outline-[var(--color-content-default-primary)] focus:outline-1 focus:outline-[var(--color-content-default-primary)]",
default:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
homeMd:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
homeUseCases:
"active:outline-[1.5px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.5px] focus:outline-[var(--color-content-default-primary)]",
largeUseCases:
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
large:
"active:outline-[1.75px] active:outline-[var(--color-content-default-primary)] focus:outline-[1.75px] focus:outline-[var(--color-content-default-primary)]",
homeXlarge:
"active:outline-[2px] active:outline-[var(--color-content-default-primary)] focus:outline-[2px] focus:outline-[var(--color-content-default-primary)]",
xlarge:
"active:outline-2 active:outline-[var(--color-content-default-primary)] focus:outline-2 focus:outline-[var(--color-content-default-primary)]",
};
const sizeStyles = {
default:
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
xsmallUseCases:
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
homeMd:
"px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
homeUseCases:
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
largeUseCases:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
homeXlarge:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)] h-[44px]",
xlarge:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)] h-[44px]",
};
const activeStateStyles = {
xsmall:
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
xsmallUseCases:
"!outline-1 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-1 focus:!outline-[var(--color-content-default-brand-primary)]",
default:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeMd:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeUseCases:
"!outline-[1.5px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.5px] focus:!outline-[var(--color-content-default-brand-primary)]",
large:
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
largeUseCases:
"!outline-[1.75px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[1.75px] focus:!outline-[var(--color-content-default-brand-primary)]",
homeXlarge:
"!outline-[2px] !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-[2px] focus:!outline-[var(--color-content-default-brand-primary)]",
xlarge:
"!outline-2 !outline-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)] focus:!outline-2 focus:!outline-[var(--color-content-default-brand-primary)]",
};
const smallTextStyle =
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
const mediumTextStyle =
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
const largeTextStyle =
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
const xlargeTextStyle =
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
const sizeStyles = {
default:
"px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-016)] gap-[var(--spacing-scale-004)]",
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
xsmallUseCases:
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
homeMd:
"px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
homeUseCases:
"px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)]",
large:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
largeUseCases:
"px-[var(--spacing-scale-012)] py-[var(--spacing-scale-012)] gap-[var(--spacing-scale-004)] h-[44px]",
homeXlarge:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)] h-[44px]",
xlarge:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] gap-[var(--spacing-scale-004)] h-[44px]",
};
const textStyles = {
default: smallTextStyle,
xsmall: smallTextStyle,
xsmallUseCases: smallTextStyle,
home: smallTextStyle,
homeMd: mediumTextStyle,
homeUseCases: mediumTextStyle,
large: largeTextStyle,
largeUseCases: largeTextStyle,
homeXlarge: xlargeTextStyle,
xlarge: xlargeTextStyle,
};
const smallTextStyle =
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]";
const mediumTextStyle =
"font-inter text-[12px] leading-[14px] font-medium tracking-[0%]";
const largeTextStyle =
"font-inter text-[16px] leading-[20px] font-medium tracking-[0%]";
const xlargeTextStyle =
"font-inter text-[24px] leading-[28px] font-normal tracking-[0%]";
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 ease-in-out cursor-pointer focus:scale-[1.02]`;
const textStyles = {
default: smallTextStyle,
xsmall: smallTextStyle,
xsmallUseCases: smallTextStyle,
home: smallTextStyle,
homeMd: mediumTextStyle,
homeUseCases: mediumTextStyle,
large: largeTextStyle,
largeUseCases: largeTextStyle,
homeXlarge: xlargeTextStyle,
xlarge: xlargeTextStyle,
};
let finalVariant = variant;
if (disabled) {
finalVariant = "default";
}
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 ease-in-out cursor-pointer focus:scale-[1.02]`;
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
finalVariant === "home"
? homeOutlineStyles[size]
: activeOutlineStyles[size]
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
let finalVariant = variant;
if (disabled) {
finalVariant = "default";
}
const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }),
role: "menuitem",
tabIndex: disabled ? -1 : 0,
...props,
};
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${
finalVariant === "home"
? homeOutlineStyles[size]
: activeOutlineStyles[size]
} ${isActive ? activeStateStyles[size] : ""} ${className}`;
const accessibilityProps = {
...(ariaLabel && { "aria-label": ariaLabel }),
...(disabled && { "aria-disabled": "true" }),
role: "menuitem",
tabIndex: disabled ? -1 : 0,
...props,
};
if (disabled) {
return (
<span className={combinedStyles} {...accessibilityProps}>
{children}
</span>
);
}
if (disabled) {
return (
<span className={combinedStyles} {...accessibilityProps}>
<a href={href} className={combinedStyles} {...accessibilityProps}>
{children}
</span>
</a>
);
}
},
);
return (
<a href={href} className={combinedStyles} {...accessibilityProps}>
{children}
</a>
);
}
MenuBarItem.displayName = "MenuBarItem";
export default MenuBarItem;
+109 -97
View File
@@ -1,112 +1,124 @@
"use client";
import React from "react";
import React, { memo } from "react";
import Image from "next/image";
const MiniCard = ({
children,
className = "",
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
panelContent,
label,
labelLine1,
labelLine2,
onClick,
href,
ariaLabel,
}) => {
const cardContent = (
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
{/* Top part - Inner panel */}
<div
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
>
{/* Content for the inner panel */}
{panelContent && (
<div className="flex items-center justify-center w-full h-full">
<Image
src={panelContent}
alt={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature icon"
}
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
unoptimized
width={0}
height={0}
sizes="100vw"
/>
</div>
)}
{children}
</div>
const MiniCard = memo(
({
children,
className = "",
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
panelContent,
label,
labelLine1,
labelLine2,
onClick,
href,
ariaLabel,
}) => {
const cardContent = (
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
{/* Top part - Inner panel */}
<div
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
>
{/* Content for the inner panel */}
{panelContent && (
<div className="flex items-center justify-center w-full h-full">
<Image
src={panelContent}
alt={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature icon"
}
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
width={58}
height={58}
sizes="(max-width: 768px) 50vw, 25vw"
loading="lazy"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
/>
</div>
)}
{children}
</div>
{/* Bottom part - Text container */}
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
{labelLine1 && labelLine2 ? (
<>
<div>{labelLine1}</div>
<div>{labelLine2}</div>
<div>&nbsp;</div>
</>
) : (
label
)}
{/* Bottom part - Text container */}
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
{labelLine1 && labelLine2 ? (
<>
<div>{labelLine1}</div>
<div>{labelLine2}</div>
<div>&nbsp;</div>
</>
) : (
label
)}
</div>
</div>
</div>
);
// If href is provided, render as a link
if (href) {
return (
<a
href={href}
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
}
tabIndex={0}
>
{cardContent}
</a>
);
}
// If onClick is provided, render as a button
if (onClick) {
return (
<button
onClick={onClick}
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
// If href is provided, render as a link
if (href) {
return (
<a
href={href}
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature card"
}
}}
tabIndex={0}
>
{cardContent}
</a>
);
}
// If onClick is provided, render as a button
if (onClick) {
return (
<button
onClick={onClick}
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
aria-label={
ariaLabel ||
`${labelLine1} ${labelLine2}` ||
label ||
"Feature card"
}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
{cardContent}
</button>
);
}
// Default render as a div
return (
<div
className="block"
aria-label={
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
}
>
{cardContent}
</button>
</div>
);
}
},
);
// Default render as a div
return (
<div
className="block"
aria-label={
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
}
>
{cardContent}
</div>
);
};
MiniCard.displayName = "MiniCard";
export default MiniCard;
+52 -43
View File
@@ -1,53 +1,62 @@
export default function NavigationItem({
href = "#",
children,
variant = "default",
size = "default",
className = "",
disabled = false,
...props
}) {
// Variant styles
const variantStyles = {
default:
"bg-transparent text-[var(--color-content-default-brand-primary)] border border-transparent hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:border-[var(--color-content-default-brand-primary)] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed",
};
import React, { memo } from "react";
// Size styles
const sizeStyles = {
default:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
};
const NavigationItem = memo(
({
href = "#",
children,
variant = "default",
size = "default",
className = "",
disabled = false,
...props
}) => {
// Variant styles
const variantStyles = {
default:
"bg-transparent text-[var(--color-content-default-brand-primary)] border border-transparent hover:bg-[var(--color-surface-default-tertiary)] hover:text-[var(--color-content-default-brand-primary)] active:bg-transparent active:text-[var(--color-content-default-brand-primary)] active:border-[var(--color-content-default-brand-primary)] disabled:bg-[var(--color-surface-default-tertiary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[var(--color-content-default-tertiary)] disabled:opacity-50 disabled:cursor-not-allowed",
};
// Text styles based on size
const textStyles = {
default: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
};
// Size styles
const sizeStyles = {
default:
"px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] gap-[var(--spacing-scale-004)]",
xsmall:
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)] gap-[var(--spacing-scale-004)]",
};
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 cursor-pointer`;
// Text styles based on size
const textStyles = {
default:
"font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
xsmall: "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]",
};
// Determine which variant to use
let finalVariant = variant;
if (disabled) {
finalVariant = "default"; // The disabled state is handled by disabled: utilities
}
const baseStyles = `inline-flex items-center ${sizeStyles[size]} rounded-[var(--radius-measures-radius-full)] ${textStyles[size]} transition-all duration-200 cursor-pointer`;
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
// Determine which variant to use
let finalVariant = variant;
if (disabled) {
finalVariant = "default"; // The disabled state is handled by disabled: utilities
}
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
if (disabled) {
return (
<span className={combinedStyles} {...props}>
{children}
</span>
);
}
if (disabled) {
return (
<span className={combinedStyles} {...props}>
<a href={href} className={combinedStyles} {...props}>
{children}
</span>
</a>
);
}
},
);
return (
<a href={href} className={combinedStyles} {...props}>
{children}
</a>
);
}
NavigationItem.displayName = "NavigationItem";
export default NavigationItem;
+5 -2
View File
@@ -1,8 +1,9 @@
"use client";
import React, { memo } from "react";
import SectionNumber from "./SectionNumber";
const NumberedCard = ({ number, text, iconShape, iconColor }) => {
const NumberedCard = memo(({ number, text, iconShape, iconColor }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
@@ -18,6 +19,8 @@ const NumberedCard = ({ number, text, iconShape, iconColor }) => {
</div>
</div>
);
};
});
NumberedCard.displayName = "NumberedCard";
export default NumberedCard;
+21 -15
View File
@@ -1,23 +1,27 @@
"use client";
import React, { memo, useMemo } from "react";
import NumberedCard from "./NumberedCard";
import SectionHeader from "./SectionHeader";
import Button from "./Button";
const NumberedCards = ({ title, subtitle, cards }) => {
// Schema markup for SEO
const schemaData = {
"@context": "https://schema.org",
"@type": "HowTo",
name: title,
description: subtitle,
step: cards.map((card, index) => ({
"@type": "HowToStep",
position: index + 1,
name: card.text,
text: card.text,
})),
};
const NumberedCards = memo(({ title, subtitle, cards }) => {
// Memoize schema data to prevent unnecessary re-computations
const schemaData = useMemo(
() => ({
"@context": "https://schema.org",
"@type": "HowTo",
name: title,
description: subtitle,
step: cards.map((card, index) => ({
"@type": "HowToStep",
position: index + 1,
name: card.text,
text: card.text,
})),
}),
[title, subtitle, cards],
);
return (
<>
@@ -70,6 +74,8 @@ const NumberedCards = ({ title, subtitle, cards }) => {
</section>
</>
);
};
});
NumberedCards.displayName = "NumberedCards";
export default NumberedCards;
+224 -219
View File
@@ -1,247 +1,252 @@
"use client";
import React, { useState } from "react";
import React, { useState, memo } from "react";
import Image from "next/image";
import QuoteDecor from "./QuoteDecor";
const QuoteBlock = ({
variant = "standard",
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "assets/Quote_Avatar.svg", // Fallback avatar
onError, // Error callback
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const QuoteBlock = memo(
({
variant = "standard",
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = "/assets/Quote_Avatar.svg",
id,
fallbackAvatarSrc = "/assets/Quote_Avatar.svg", // Fallback avatar
onError, // Error callback
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Variant configurations
const variants = {
compact: {
container: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
avatarGap: "gap-[var(--spacing-scale-012)]",
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
author: "text-[10px] leading-[120%] md:text-[12px]",
source: "text-[10px] leading-[120%] md:text-[12px]",
showDecor: false,
},
standard: {
container:
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
avatarGap:
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
avatar:
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
quote:
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
author:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
source:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
showDecor: true,
},
extended: {
container:
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
avatarGap:
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
avatar:
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
quote:
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
author:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
source:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
};
// Variant configurations
const variants = {
compact: {
container:
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
avatarGap: "gap-[var(--spacing-scale-012)]",
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
author: "text-[10px] leading-[120%] md:text-[12px]",
source: "text-[10px] leading-[120%] md:text-[12px]",
showDecor: false,
},
standard: {
container:
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
avatarGap:
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
avatar:
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
quote:
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
author:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
source:
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
showDecor: true,
},
extended: {
container:
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
avatarGap:
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
avatar:
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
quote:
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
author:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
source:
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
showDecor: true,
},
};
const config = variants[variant] || variants.standard;
const config = variants[variant] || variants.standard;
// Use provided ID or generate a stable one based on content
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Use provided ID or generate a stable one based on content
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
const quoteId = `${baseId}-content`;
const authorId = `${baseId}-author`;
// Error handling functions
const handleImageError = (error) => {
console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error,
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
message: `Failed to load avatar for ${author}`,
author,
avatarSrc,
// Error handling functions
const handleImageError = (error) => {
console.warn(
`QuoteBlock: Failed to load avatar image for ${author}:`,
error,
});
);
setImageError(true);
setImageLoading(false);
// Call error callback if provided
if (onError) {
onError({
type: "image_load_error",
message: `Failed to load avatar for ${author}`,
author,
avatarSrc,
error,
});
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Validate required props
if (!quote || !author) {
console.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
type: "missing_props",
message: "QuoteBlock requires quote and author props",
quote: !!quote,
author: !!author,
});
}
return null; // Don't render if missing required props
}
};
const handleImageLoad = () => {
setImageLoading(false);
setImageError(false);
};
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
// Validate required props
if (!quote || !author) {
console.error("QuoteBlock: Missing required props (quote or author)");
if (onError) {
onError({
type: "missing_props",
message: "QuoteBlock requires quote and author props",
quote: !!quote,
author: !!author,
});
}
return null; // Don't render if missing required props
}
// Determine which avatar to use
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
return (
<section
className={`${config.container} ${className}`}
aria-labelledby={quoteId}
role="region"
>
<div
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
return (
<section
className={`${config.container} ${className}`}
aria-labelledby={quoteId}
role="region"
>
{/* Background with noise texture */}
<div
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
style={{
filter:
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
}}
/>
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
>
{/* Background with noise texture */}
<div
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
style={{
filter:
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
}}
/>
{/* DECORATIONS (behind content) */}
{config.showDecor && (
<QuoteDecor
className="pointer-events-none absolute z-0
{/* DECORATIONS (behind content) */}
{config.showDecor && (
<QuoteDecor
className="pointer-events-none absolute z-0
left-0 top-0
w-full h-full"
aria-hidden="true"
/>
)}
aria-hidden="true"
/>
)}
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative">
{!imageError ? (
<Image
src={avatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={handleImageError}
onLoad={handleImageLoad}
/>
) : null}
<div className={`flex flex-col ${config.gap} relative z-10`}>
<div className={`flex flex-col ${config.avatarGap}`}>
{/* Avatar with error handling */}
<div className="relative">
{!imageError ? (
<Image
src={avatarSrc}
alt={`Portrait of ${author}`}
width={64}
height={64}
className={`filter sepia ${
config.avatar
} transition-opacity duration-300 ${
imageLoading ? "opacity-0" : "opacity-100"
}`}
loading="lazy"
onError={handleImageError}
onLoad={handleImageLoad}
/>
) : null}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Loading state */}
{imageLoading && !imageError && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
/>
)}
{/* Error state - show initials */}
{imageError && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
{/* Error state - show initials */}
{imageError && (
<div
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)}
</div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-bricolage-grotesque font-normal",
config.quote,
"text-[var(--color-content-inverse-primary)]",
// give space for the hanging open-quote so it's not clipped:
"pl-[0.6em] -indent-[0.6em]",
// inject quotes
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
// lock quote glyphs to your display face
"before:[font-family:var(--font-bricolage-grotesque)]",
"after:[font-family:var(--font-bricolage-grotesque)]",
].join(" ")}
>
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
{author
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()}
</span>
</div>
)}
{quote}
</p>
</blockquote>
</div>
<blockquote
id={quoteId}
aria-labelledby={authorId}
className="relative"
>
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-bricolage-grotesque font-normal",
config.quote,
"text-[var(--color-content-inverse-primary)]",
// give space for the hanging open-quote so it's not clipped:
"pl-[0.6em] -indent-[0.6em]",
// inject quotes
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
// lock quote glyphs to your display face
"before:[font-family:var(--font-bricolage-grotesque)]",
"after:[font-family:var(--font-bricolage-grotesque)]",
].join(" ")}
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{quote}
</p>
</blockquote>
{author}
</cite>
{source && (
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-inter font-normal",
config.source,
"text-[var(--color-content-inverse-primary)] uppercase",
"pl-[0.6em] -indent-[0.6em]",
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
].join(" ")}
>
{source}
</p>
)}
</footer>
</div>
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
<cite
id={authorId}
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
>
{author}
</cite>
{source && (
<p
data-qopen="&ldquo;"
data-qclose="&rdquo;"
className={[
"font-inter font-normal",
config.source,
"text-[var(--color-content-inverse-primary)] uppercase",
"pl-[0.6em] -indent-[0.6em]",
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
].join(" ")}
>
{source}
</p>
)}
</footer>
</div>
</div>
</section>
);
};
</section>
);
},
);
QuoteBlock.displayName = "QuoteBlock";
export default QuoteBlock;
+6 -2
View File
@@ -1,6 +1,8 @@
"use client";
const QuoteDecor = ({ className = "" }) => {
import React, { memo } from "react";
const QuoteDecor = memo(({ className = "" }) => {
return (
<svg
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
@@ -68,6 +70,8 @@ const QuoteDecor = ({ className = "" }) => {
</g>
</svg>
);
};
});
QuoteDecor.displayName = "QuoteDecor";
export default QuoteDecor;
+141 -130
View File
@@ -1,152 +1,163 @@
"use client";
import { useState, useEffect } from "react";
import React, { useState, useEffect, memo, useMemo, useCallback } from "react";
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
export default function RelatedArticles({
relatedPosts,
currentPostSlug,
slugOrder = [],
}) {
// Filter out the current post from related posts
const filteredPosts = relatedPosts.filter(
(post) => post.slug !== currentPostSlug,
);
const RelatedArticles = memo(
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
// Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
[relatedPosts, currentPostSlug],
);
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [isMobile, setIsMobile] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [isMobile, setIsMobile] = useState(true);
// Check if we're on mobile (below lg breakpoint)
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
};
// Memoize the mouse down handler to prevent unnecessary re-renders
const handleMouseDown = useCallback((e) => {
const slider = e.currentTarget;
const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
const handleMouseMove = (e) => {
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
};
// Auto-advance every 3 seconds (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const interval = setInterval(() => {
setProgress(0);
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
}, 3000);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, []);
return () => clearInterval(interval);
}, [filteredPosts.length, isMobile]);
// Memoize transform style to prevent unnecessary recalculations
const transformStyle = useMemo(
() => ({
transform: isMobile
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
: "none",
scrollBehavior: !isMobile ? "smooth" : "auto",
}),
[isMobile, currentIndex],
);
// Progress animation (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
// Memoize progress bar style calculation
const getProgressStyle = useCallback(
(index) => ({
width:
index === currentIndex
? `${progress}%`
: index < currentIndex
? "100%"
: "0%",
}),
[currentIndex, progress],
);
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
return 0;
}
return prev + 1;
});
}, 30); // 30ms intervals for smooth animation
// Check if we're on mobile (below lg breakpoint)
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
};
return () => clearInterval(progressInterval);
}, [currentIndex, filteredPosts.length, isMobile]);
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
if (filteredPosts.length === 0) {
return null;
}
// Auto-advance every 3 seconds (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
return (
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
Related Articles
</h2>
const interval = setInterval(() => {
setProgress(0);
setCurrentIndex((prev) => (prev + 1) % filteredPosts.length);
}, 3000);
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
<div className="flex justify-center overflow-hidden">
<div
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
!isMobile
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
: ""
}`}
style={{
transform: isMobile
? `translateX(calc(50% - 130px - ${currentIndex * 260}px))`
: "none",
scrollBehavior: !isMobile ? "smooth" : "auto",
}}
onMouseDown={
!isMobile
? (e) => {
const slider = e.currentTarget;
const startX = e.pageX - slider.offsetLeft;
const scrollLeft = slider.scrollLeft;
return () => clearInterval(interval);
}, [filteredPosts.length, isMobile]);
const handleMouseMove = (e) => {
const x = e.pageX - slider.offsetLeft;
const walk = (x - startX) * 2;
slider.scrollLeft = scrollLeft - walk;
};
// Progress animation (only on mobile)
useEffect(() => {
if (filteredPosts.length <= 1 || !isMobile) return;
const handleMouseUp = () => {
document.removeEventListener(
"mousemove",
handleMouseMove,
);
document.removeEventListener("mouseup", handleMouseUp);
};
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
return 0;
}
return prev + 1;
});
}, 30); // 30ms intervals for smooth animation
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
: undefined
}
>
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
>
<ContentThumbnailTemplate
post={relatedPost}
variant="vertical"
slugOrder={slugOrder}
/>
</div>
))}
</div>
</div>
return () => clearInterval(progressInterval);
}, [currentIndex, filteredPosts.length, isMobile]);
{/* Progress bars - only show on mobile */}
{isMobile && (
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
>
if (filteredPosts.length === 0) {
return null;
}
return (
<section className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]">
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
Related Articles
</h2>
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
<div className="flex justify-center overflow-hidden">
<div
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
!isMobile
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
: ""
}`}
style={transformStyle}
onMouseDown={!isMobile ? handleMouseDown : undefined}
>
{filteredPosts.map((relatedPost, index) => (
<div
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
style={{
width:
index === currentIndex
? `${progress}%`
: index < currentIndex
? "100%"
: "0%",
}}
/>
</div>
))}
key={relatedPost.slug}
className="flex flex-col items-center flex-shrink-0"
>
<ContentThumbnailTemplate
post={relatedPost}
variant="vertical"
slugOrder={slugOrder}
/>
</div>
))}
</div>
</div>
)}
</div>
</section>
);
}
{/* Progress bars - only show on mobile */}
{isMobile && (
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
{filteredPosts.map((relatedPost, index) => (
<div
key={relatedPost.slug}
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
>
<div
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
style={getProgressStyle(index)}
/>
</div>
))}
</div>
)}
</div>
</section>
);
},
);
RelatedArticles.displayName = "RelatedArticles";
export default RelatedArticles;
+69 -63
View File
@@ -1,73 +1,79 @@
"use client";
const RuleCard = ({
title,
description,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
}) => {
const handleClick = () => {
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
template_name: title,
template_type: "governance_pattern",
});
}
import React, { memo } from "react";
// Custom analytics event for other tracking systems
if (typeof window !== "undefined" && window.analytics) {
window.analytics.track("Template Selected", {
templateName: title,
templateType: "governance_pattern",
});
}
const RuleCard = memo(
({
title,
description,
icon,
backgroundColor = "bg-[var(--color-community-teal-100)]",
className = "",
onClick,
}) => {
const handleClick = () => {
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
template_name: title,
template_type: "governance_pattern",
});
}
if (onClick) onClick();
};
// Custom analytics event for other tracking systems
if (typeof window !== "undefined" && window.analytics) {
window.analytics.track("Template Selected", {
templateName: title,
templateType: "governance_pattern",
});
}
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
if (onClick) onClick();
};
return (
<div
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
tabIndex={0}
role="button"
aria-label={`Learn more about ${title} governance pattern`}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{/* Header Container */}
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
{/* Icon Container */}
{icon && (
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
{icon}
</div>
)}
{/* Title Container */}
{title && (
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
{title}
</h3>
</div>
const handleKeyDown = (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
};
return (
<div
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
tabIndex={0}
role="button"
aria-label={`Learn more about ${title} governance pattern`}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{/* Header Container */}
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
{/* Icon Container */}
{icon && (
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
{icon}
</div>
)}
{/* Title Container */}
{title && (
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
{title}
</h3>
</div>
)}
</div>
{description && (
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
{description}
</p>
)}
</div>
{description && (
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
{description}
</p>
)}
</div>
);
};
);
},
);
RuleCard.displayName = "RuleCard";
export default RuleCard;
+5 -3
View File
@@ -1,12 +1,12 @@
"use client";
import React from "react";
import React, { memo } from "react";
import Image from "next/image";
import RuleCard from "./RuleCard";
import Button from "./Button";
import { getAssetPath } from "../../lib/assetUtils";
const RuleStack = ({ className = "" }) => {
const RuleStack = memo(({ className = "" }) => {
const handleTemplateClick = (templateName) => {
// Basic analytics tracking
if (typeof window !== "undefined") {
@@ -99,6 +99,8 @@ const RuleStack = ({ className = "" }) => {
</div>
</section>
);
};
});
RuleStack.displayName = "RuleStack";
export default RuleStack;
+45 -39
View File
@@ -1,54 +1,60 @@
"use client";
const SectionHeader = ({ title, subtitle, titleLg, variant = "default" }) => {
return (
<div
className={
variant === "multi-line"
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
}
>
{/* Title Container - Left side (lg breakpoint) */}
<div
className={
variant === "multi-line"
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
}
>
<h2
className={
variant === "multi-line"
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
}
>
<span className="block lg:hidden">{title}</span>
<span className="hidden lg:block">{titleLg || title}</span>
</h2>
</div>
import React, { memo } from "react";
{/* Subtitle Container */}
const SectionHeader = memo(
({ title, subtitle, titleLg, variant = "default" }) => {
return (
<div
className={
variant === "multi-line"
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-[0px] xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
? "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
: "flex flex-col gap-[var(--spacing-scale-004)] w-full lg:flex-row lg:justify-between lg:items-start xl:gap-[var(--spacing-scale-024)]"
}
>
<p
{/* Title Container - Left side (lg breakpoint) */}
<div
className={
variant === "multi-line"
? "font-inter font-normal text-[14px] leading-[20px] md:font-normal md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)]"
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[50%] xl:h-[156px] xl:flex xl:items-center"
: "lg:w-[369px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center xl:w-[452px] xl:h-[156px] xl:flex xl:items-center"
}
>
{subtitle}
</p>
<h2
className={
variant === "multi-line"
? "font-bricolage-grotesque font-bold text-[28px] leading-[36px] md:font-bold md:text-[32px] md:leading-[40px] lg:w-[410px] lg:text-left xl:text-[40px] xl:leading-[52px] text-[var(--color-content-default-primary)]"
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px] sm:text-[32px] sm:leading-[40px] lg:text-[32px] lg:leading-[40px] lg:w-[369px] lg:pr-[var(--spacing-scale-096)] xl:text-[40px] xl:leading-[52px] xl:w-[452px] xl:pr-[var(--spacing-scale-096)] text-[var(--color-content-default-primary)]"
}
>
<span className="block lg:hidden">{title}</span>
<span className="hidden lg:block">{titleLg || title}</span>
</h2>
</div>
{/* Subtitle Container */}
<div
className={
variant === "multi-line"
? "lg:w-[50%] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end lg:ml-[var(--spacing-scale-016)] xl:ml-[0px] xl:w-[50%] xl:h-[156px] xl:flex xl:items-center xl:justify-end"
: "lg:w-[928px] lg:h-[var(--spacing-scale-120)] lg:flex lg:items-center lg:justify-end xl:h-[156px] xl:flex xl:items-center xl:justify-end"
}
>
<p
className={
variant === "multi-line"
? "font-inter font-normal text-[14px] leading-[20px] md:font-normal md:text-[18px] md:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-default-tertiary)]"
: "font-inter font-normal text-[18px] leading-[130%] sm:text-[18px] sm:leading-[32px] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] xl:text-right text-[#484848] sm:text-[var(--color-content-default-tertiary)] lg:text-[var(--color-content-default-tertiary)] xl:text-[var(--color-content-default-tertiary)] tracking-[0px]"
}
>
{subtitle}
</p>
</div>
</div>
</div>
);
};
);
},
);
SectionHeader.displayName = "SectionHeader";
export default SectionHeader;
+10 -6
View File
@@ -1,16 +1,18 @@
"use client";
const SectionNumber = ({ number }) => {
import React, { memo } from "react";
const SectionNumber = memo(({ number }) => {
const getImageSrc = (num) => {
switch (num) {
case 1:
return "assets/SectionNumber_1.png";
return "/assets/SectionNumber_1.png";
case 2:
return "assets/SectionNumber_2.png";
return "/assets/SectionNumber_2.png";
case 3:
return "assets/SectionNumber_3.png";
return "/assets/SectionNumber_3.png";
default:
return "assets/SectionNumber_1.png";
return "/assets/SectionNumber_1.png";
}
};
@@ -28,6 +30,8 @@ const SectionNumber = ({ number }) => {
</div>
</div>
);
};
});
SectionNumber.displayName = "SectionNumber";
export default SectionNumber;
+8 -2
View File
@@ -1,7 +1,13 @@
export default function Separator() {
import React, { memo } from "react";
const Separator = memo(() => {
return (
<div className="flex flex-col items-center self-stretch">
<div className="flex items-start self-stretch h-px w-full bg-[var(--border-color-default-secondary)]" />
</div>
);
}
});
Separator.displayName = "Separator";
export default Separator;
+245
View File
@@ -0,0 +1,245 @@
"use client";
import React, { useState, useEffect, memo } from "react";
const WebVitalsDashboard = memo(() => {
const [vitals, setVitals] = useState({
lcp: { value: 0, rating: "unknown" },
fid: { value: 0, rating: "unknown" },
cls: { value: 0, rating: "unknown" },
fcp: { value: 0, rating: "unknown" },
ttfb: { value: 0, rating: "unknown" },
});
const [metrics, setMetrics] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch Web Vitals data from API
const fetchVitals = async () => {
try {
const response = await fetch("/api/web-vitals");
const data = await response.json();
setMetrics(data.metrics || {});
} catch (error) {
console.error("Error fetching web vitals:", error);
} finally {
setLoading(false);
}
};
fetchVitals();
// Set up Web Vitals tracking
if (typeof window !== "undefined") {
import("web-vitals").then(
({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
// Track Largest Contentful Paint
getLCP((metric) => {
setVitals((prev) => ({
...prev,
lcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track First Input Delay
getFID((metric) => {
setVitals((prev) => ({
...prev,
fid: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Cumulative Layout Shift
getCLS((metric) => {
setVitals((prev) => ({
...prev,
cls: {
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
},
}));
});
// Track First Contentful Paint
getFCP((metric) => {
setVitals((prev) => ({
...prev,
fcp: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
// Track Time to First Byte
getTTFB((metric) => {
setVitals((prev) => ({
...prev,
ttfb: {
value: Math.round(metric.value),
rating: metric.rating,
},
}));
});
},
);
}
}, []);
const getRatingColor = (rating) => {
switch (rating) {
case "good":
return "text-green-600 bg-green-50";
case "needs-improvement":
return "text-yellow-600 bg-yellow-50";
case "poor":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
const getRatingIcon = (rating) => {
switch (rating) {
case "good":
return "✅";
case "needs-improvement":
return "⚠️";
case "poor":
return "❌";
default:
return "❓";
}
};
const formatValue = (metric, value) => {
if (metric === "cls") {
return value.toFixed(3);
}
return `${value}ms`;
};
if (loading) {
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="p-4 border rounded-lg">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-[var(--color-content-default-primary)]">
Web Vitals Dashboard
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{Object.entries(vitals).map(([metric, data]) => (
<div
key={metric}
className={`p-4 border rounded-lg ${getRatingColor(data.rating)}`}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
</div>
<div className="text-sm">
<div className="font-medium">
Value: {formatValue(metric, data.value)}
</div>
<div className="capitalize">
Rating: {data.rating.replace("-", " ")}
</div>
</div>
</div>
))}
</div>
{/* Historical Metrics */}
{Object.keys(metrics).length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4 text-[var(--color-content-default-primary)]">
Historical Metrics
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(metrics).map(([metric, data]) => (
<div
key={metric}
className="p-4 border rounded-lg bg-[var(--color-surface-default-secondary)]"
>
<h4 className="font-semibold mb-2">{metric.toUpperCase()}</h4>
<div className="text-sm space-y-1">
<div>Count: {data.count}</div>
<div>Average: {formatValue(metric, data.average)}</div>
<div>
Range: {formatValue(metric, data.min)} -{" "}
{formatValue(metric, data.max)}
</div>
<div className="flex gap-2 text-xs">
<span className="text-green-600">
Good: {data.goodCount}
</span>
<span className="text-yellow-600">
Needs Improvement: {data.needsImprovementCount}
</span>
<span className="text-red-600">Poor: {data.poorCount}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Performance Guidelines */}
<div className="p-4 bg-[var(--color-surface-default-secondary)] rounded-lg">
<h3 className="font-semibold mb-2 text-[var(--color-content-default-primary)]">
Performance Guidelines
</h3>
<ul className="text-sm space-y-1 text-[var(--color-content-default-secondary)]">
<li>
<strong>LCP:</strong> Good &lt; 2.5s, Needs Improvement 2.5-4s,
Poor &gt; 4s
</li>
<li>
<strong>FID:</strong> Good &lt; 100ms, Needs Improvement
100-300ms, Poor &gt; 300ms
</li>
<li>
<strong>CLS:</strong> Good &lt; 0.1, Needs Improvement 0.1-0.25,
Poor &gt; 0.25
</li>
<li>
<strong>FCP:</strong> Good &lt; 1.8s, Needs Improvement 1.8-3s,
Poor &gt; 3s
</li>
<li>
<strong>TTFB:</strong> Good &lt; 800ms, Needs Improvement
800-1800ms, Poor &gt; 1800ms
</li>
</ul>
</div>
</div>
);
});
WebVitalsDashboard.displayName = "WebVitalsDashboard";
export default WebVitalsDashboard;
+6
View File
@@ -10,6 +10,8 @@ const inter = Inter({
weight: ["400", "500", "600", "700"],
variable: "--font-inter",
display: "swap",
preload: true,
fallback: ["system-ui", "arial"],
});
const bricolageGrotesque = Bricolage_Grotesque({
@@ -17,6 +19,8 @@ const bricolageGrotesque = Bricolage_Grotesque({
weight: ["400", "500", "700", "800"],
variable: "--font-bricolage-grotesque",
display: "swap",
preload: true,
fallback: ["system-ui", "arial"],
});
const spaceGrotesk = Space_Grotesk({
@@ -24,6 +28,8 @@ const spaceGrotesk = Space_Grotesk({
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
preload: true,
fallback: ["system-ui", "arial"],
});
export const metadata = {
+160
View File
@@ -0,0 +1,160 @@
import React from "react";
import WebVitalsDashboard from "../components/WebVitalsDashboard";
import Header from "../components/Header";
import Footer from "../components/Footer";
export default function MonitorPage() {
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<Header />
<main className="container mx-auto px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)]">
<div className="max-w-6xl mx-auto">
<div className="mb-[var(--spacing-scale-032)]">
<h1 className="text-4xl font-bold text-[var(--color-content-default-primary)] mb-[var(--spacing-scale-016)]">
Performance Monitoring
</h1>
<p className="text-[var(--font-size-body-large)] text-[var(--color-content-default-secondary)]">
Real-time monitoring of Core Web Vitals and performance metrics
for Community Rule 3.0
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[var(--spacing-scale-032)] mb-[var(--spacing-scale-032)]">
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
Performance Targets
</h2>
<div className="space-y-[var(--spacing-scale-012)]">
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
Load Time
</span>
<span className="font-semibold text-green-600">
&lt; 2 seconds
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
LCP
</span>
<span className="font-semibold text-green-600">
&lt; 2.5s
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
FID
</span>
<span className="font-semibold text-green-600">
&lt; 100ms
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
CLS
</span>
<span className="font-semibold text-green-600">&lt; 0.1</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--font-size-body-medium)]">
Lighthouse Score
</span>
<span className="font-semibold text-green-600">&gt; 90</span>
</div>
</div>
</div>
<div className="p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
Optimization Status
</h2>
<div className="space-y-[var(--spacing-scale-012)]">
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
Code Splitting & Dynamic Imports
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
React.memo Optimizations
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
Image Optimization
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
Font Preloading
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
Bundle Analysis
</span>
</div>
<div className="flex items-center gap-[var(--spacing-scale-008)]">
<span className="text-green-600"></span>
<span className="text-[var(--font-size-body-medium)]">
Error Boundaries
</span>
</div>
</div>
</div>
</div>
<WebVitalsDashboard />
<div className="mt-[var(--spacing-scale-032)] p-[var(--spacing-scale-024)] bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-medium)]">
<h2 className="text-2xl font-semibold mb-[var(--spacing-scale-016)] text-[var(--color-content-default-primary)]">
Monitoring Commands
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-[var(--spacing-scale-016)]">
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
Bundle Analysis
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
npm run bundle:analyze
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
Performance Monitor
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
npm run performance:monitor
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
Web Vitals Tracking
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
npm run web-vitals:track
</code>
</div>
<div>
<h3 className="font-semibold mb-[var(--spacing-scale-008)] text-[var(--color-content-default-primary)]">
All Monitoring
</h3>
<code className="block p-[var(--spacing-scale-008)] bg-[var(--color-surface-inverse-brand-primary)] text-[var(--color-content-inverse-primary)] rounded-[var(--radius-measures-radius-small)] text-sm">
npm run monitor:all
</code>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}
+19
View File
@@ -44,6 +44,19 @@ This directory contains comprehensive testing documentation for the CommunityRul
**Use this for**: Visual regression testing, snapshot management, visual test troubleshooting
### 4. [performance-optimization-guide.md](./performance-optimization-guide.md) - **Performance Guide**
**Comprehensive performance optimization documentation**:
- Performance targets and metrics
- Frontend optimizations (React.memo, code splitting, image optimization)
- Performance monitoring and bundle analysis
- Web Vitals tracking and dashboard
- Performance testing and troubleshooting
- Best practices and optimization strategies
**Use this for**: Performance optimization, monitoring, bundle analysis, Web Vitals tracking
## 🎯 How to Use These Documents
### For New Team Members
@@ -51,18 +64,21 @@ This directory contains comprehensive testing documentation for the CommunityRul
1. Start with **testing-framework.md** to understand the complete testing strategy
2. Use **testing-quick-reference.md** for daily development
3. Reference **visual-regression-guide.md** when working with visual tests
4. Review **performance-optimization-guide.md** for performance optimization
### For Daily Development
1. Use **testing-quick-reference.md** for commands and troubleshooting
2. Reference **testing-framework.md** for detailed explanations
3. Use **visual-regression-guide.md** for visual test workflows
4. Use **performance-optimization-guide.md** for performance monitoring
### For Troubleshooting
1. Check **testing-quick-reference.md** for common solutions
2. Use **testing-framework.md** for detailed troubleshooting
3. Reference **visual-regression-guide.md** for visual test issues
4. Use **performance-optimization-guide.md** for performance issues
## 📊 Current Testing Status
@@ -72,6 +88,9 @@ This directory contains comprehensive testing documentation for the CommunityRul
- **Visual Regression**: 23 tests per browser
- **Accessibility**: WCAG 2.1 AA compliance
- **Performance**: Lighthouse CI with budgets
- **Bundle Analysis**: Real-time monitoring with budgets
- **Web Vitals Tracking**: Core Web Vitals collection
- **Performance Optimization**: React.memo + code splitting
## 🔄 Documentation Updates
+391
View File
@@ -0,0 +1,391 @@
# Performance Optimization Guide
## 📋 Table of Contents
- [Overview](#overview)
- [Performance Targets](#performance-targets)
- [Frontend Optimizations](#frontend-optimizations)
- [Performance Monitoring](#performance-monitoring)
- [Bundle Analysis](#bundle-analysis)
- [Web Vitals Tracking](#web-vitals-tracking)
- [Performance Testing](#performance-testing)
- [Troubleshooting](#troubleshooting)
- [Best Practices](#best-practices)
## 🎯 Overview
This guide covers the comprehensive performance optimization strategy implemented in Community Rule 3.0 to achieve sub-2-second load times across all platform features.
### Performance Philosophy
- **Measure First**: Comprehensive monitoring before optimization
- **Performance Budgets**: Enforce limits to prevent regression
- **Real User Monitoring**: Track actual user experience
- **Continuous Optimization**: Regular monitoring and improvement
## 🎯 Performance Targets
### Core Web Vitals
- **LCP (Largest Contentful Paint)**: < 2.5s (Good)
- **FID (First Input Delay)**: < 100ms (Good)
- **CLS (Cumulative Layout Shift)**: < 0.1 (Good)
- **FCP (First Contentful Paint)**: < 1.8s (Good)
- **TTFB (Time to First Byte)**: < 800ms (Good)
### Bundle Size Targets
- **Initial JavaScript Bundle**: < 250KB gzipped (currently 101KB)
- **Total Bundle Size**: < 2MB
- **Individual Component Bundles**: < 50KB
- **Image Assets**: Optimized with WebP/AVIF formats
### Lighthouse Scores
- **Performance**: > 90
- **Accessibility**: > 90
- **Best Practices**: > 90
- **SEO**: > 90
## ⚡ Frontend Optimizations
### 1. Code Splitting
Dynamic imports for non-critical components to reduce initial bundle size:
```javascript
// Dynamic imports for non-critical components
const NumberedCards = dynamic(() => import("./components/NumberedCards"), {
loading: () => <div className="loading-placeholder">Loading...</div>,
});
const LogoWall = dynamic(() => import("./components/LogoWall"), {
loading: () => <div className="loading-placeholder">Loading...</div>,
});
```
### 2. React.memo Optimization
Applied to all 30+ components to prevent unnecessary re-renders:
```javascript
import React, { memo } from "react";
const MyComponent = memo(({ prop1, prop2 }) => {
return <div>{/* Component content */}</div>;
});
MyComponent.displayName = "MyComponent";
export default MyComponent;
```
### 3. useMemo and useCallback
Optimized expensive computations and event handlers:
```javascript
import React, { memo, useMemo, useCallback } from "react";
const OptimizedComponent = memo(({ data, onAction }) => {
// Memoize expensive computations
const processedData = useMemo(() => {
return data.map((item) => expensiveOperation(item));
}, [data]);
// Memoize event handlers
const handleClick = useCallback(
(id) => {
onAction(id);
},
[onAction],
);
return <div onClick={handleClick}>{/* Component content */}</div>;
});
```
### 4. Image Optimization
Enhanced `next/image` with lazy loading and blur placeholders:
```javascript
import Image from "next/image";
<Image
src="/assets/image.jpg"
alt="Description"
width={300}
height={200}
sizes="(max-width: 768px) 100vw, 50vw"
loading="lazy"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>;
```
### 5. Font Optimization
Preloading and fallbacks for all fonts:
```javascript
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
preload: true,
fallback: ["system-ui", "arial"],
});
const bricolageGrotesque = Bricolage_Grotesque({
subsets: ["latin"],
preload: true,
fallback: ["system-ui", "arial"],
});
```
### 6. Error Boundaries
Comprehensive error handling to prevent cascade failures:
```javascript
import React, { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <div>Something went wrong.</div>;
}
return this.props.children;
}
}
```
## 📊 Performance Monitoring
### Available Scripts
```bash
# Individual monitoring tools
npm run bundle:analyze # Analyze bundle sizes and budgets
npm run performance:monitor # Performance metrics and Lighthouse CI
npm run web-vitals:track # Core Web Vitals tracking
# Comprehensive testing
npm run test:performance # All performance tests
npm run monitor:all # All monitoring tools
```
### Performance Dashboard
Access the performance monitoring dashboard at `/monitor` to view:
- Real-time Web Vitals metrics
- Historical performance data
- Bundle analysis results
- Performance budget status
- Optimization recommendations
## 📦 Bundle Analysis
### Bundle Analyzer Script
The bundle analyzer provides comprehensive analysis of bundle sizes:
```bash
npm run bundle:analyze
```
**Features:**
- Analyzes static assets, chunks, and pages
- Checks against performance budgets
- Generates optimization recommendations
- Saves results in JSON and Markdown formats
**Output Files:**
- `.next/analyze/bundle-analysis.json` - Detailed analysis data
- `.next/analyze/bundle-report.md` - Human-readable report
### Performance Budgets
Defined in `performance-budgets.json`:
```json
{
"budgets": [
{
"name": "lcp",
"maxValue": 2500,
"description": "Largest Contentful Paint"
},
{
"name": "bundle-size",
"maxSizeKB": 250,
"description": "Initial JavaScript bundle size"
}
]
}
```
## 📈 Web Vitals Tracking
### Real-time Monitoring
The Web Vitals tracking system collects and reports Core Web Vitals:
```bash
npm run web-vitals:track
```
**Features:**
- Collects LCP, FID, CLS, FCP, TTFB metrics
- Stores historical data (last 100 entries per metric)
- Generates summary reports
- Provides optimization recommendations
**API Endpoint:**
- `POST /api/web-vitals` - Receives Web Vitals data
- `GET /api/web-vitals` - Returns aggregated metrics
### Web Vitals Dashboard
The dashboard component displays real-time and historical metrics:
```javascript
import WebVitalsDashboard from "./components/WebVitalsDashboard";
<WebVitalsDashboard />;
```
## 🧪 Performance Testing
### Comprehensive Testing
Run all performance tests with a single command:
```bash
npm run test:performance
```
**Test Coverage:**
- Bundle analysis with budget checking
- Performance monitoring with Lighthouse CI
- Web Vitals tracking setup
- Comprehensive reporting
### Individual Tests
```bash
# Bundle analysis only
npm run bundle:analyze
# Performance monitoring only
npm run performance:monitor
# Web Vitals tracking only
npm run web-vitals:track
# All monitoring tools
npm run monitor:all
```
## 🔧 Troubleshooting
### Common Issues
#### 1. Bundle Size Exceeds Budget
```bash
# Check bundle analysis
npm run bundle:analyze
# Review recommendations in .next/analyze/bundle-report.md
# Consider code splitting or removing unused dependencies
```
#### 2. Web Vitals Poor Performance
```bash
# Check Web Vitals data
npm run web-vitals:track
# Review dashboard at /monitor
# Optimize images, fonts, or JavaScript
```
#### 3. Performance Tests Failing
```bash
# Run comprehensive performance test
npm run test:performance
# Check individual components
npm run bundle:analyze
npm run performance:monitor
```
### Debug Commands
```bash
# Debug bundle analysis
npm run bundle:analyze --verbose
# Debug performance monitoring
npm run performance:monitor --debug
# Check Web Vitals data
curl http://localhost:3000/api/web-vitals
```
## 🎯 Best Practices
### Development
1. **Always use React.memo** for components that receive props
2. **Implement useMemo/useCallback** for expensive operations
3. **Use dynamic imports** for non-critical components
4. **Optimize images** with proper sizing and formats
5. **Preload critical fonts** and resources
### Monitoring
1. **Run bundle analysis** before major releases
2. **Monitor Web Vitals** in production
3. **Check performance budgets** in CI/CD
4. **Review optimization recommendations** regularly
### Performance Budgets
1. **Set realistic budgets** based on user needs
2. **Monitor budget violations** in CI/CD
3. **Optimize when budgets are exceeded**
4. **Update budgets** as requirements change
## 📚 Additional Resources
- **Next.js Performance**: https://nextjs.org/docs/advanced-features/measuring-performance
- **Web Vitals**: https://web.dev/vitals/
- **Lighthouse CI**: https://github.com/GoogleChrome/lighthouse-ci
- **React Performance**: https://react.dev/learn/render-and-commit
---
**Last Updated**: December 2024
**Maintained by**: CommunityRule Development Team
+43 -6
View File
@@ -31,11 +31,14 @@ The CommunityRule platform uses a comprehensive testing framework with multiple
### Current Status
-**305 Unit Tests** (94.88% coverage - exceeds 85% target)
-**428 Unit Tests** (94.88% coverage - exceeds 85% target)
-**92 E2E Tests** across 4 browsers
-**23 Visual Regression Tests** per browser
-**Performance Budgets** with Lighthouse CI
-**WCAG 2.1 AA Compliance** with automated testing
-**Bundle Analysis** with automated monitoring
-**Web Vitals Tracking** with real-time metrics
-**Performance Optimization** with React.memo and code splitting
## 🏗 Testing Architecture
@@ -438,8 +441,10 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
### Framework
- **Lighthouse CI**: Automated performance testing
- **Performance Budgets**: Defined thresholds
- **Core Web Vitals**: LCP, FID, CLS monitoring
- **Bundle Analysis**: Real-time bundle size monitoring
- **Web Vitals Tracking**: Core Web Vitals collection and reporting
- **Performance Monitoring**: Comprehensive performance metrics
- **Performance Budgets**: Defined thresholds with automated enforcement
### Configuration
@@ -472,6 +477,7 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
- **Performance Score**: >80
- **Accessibility Score**: >80
- **Best Practices**: >90
- **Bundle Size**: <250KB gzipped (currently 101KB)
### Performance Budgets
@@ -479,15 +485,46 @@ npx playwright test tests/accessibility/e2e/wcag-compliance.spec.ts
- **Largest Contentful Paint**: <5000ms
- **First Input Delay**: <100ms
- **TTFB**: <700ms
- **Bundle Size**: <250KB gzipped
- **Total Bundle Size**: <2MB
### Performance Optimizations
- **✅ Code Splitting**: Dynamic imports for non-critical components
- **✅ React.memo**: Applied to all 30+ components
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading
- **✅ Font Optimization**: Preloading and fallbacks
- **✅ Bundle Analysis**: Real-time monitoring with budgets
- **✅ Error Boundaries**: Comprehensive error handling
### Available Scripts
```bash
npm run lhci # Run Lighthouse CI
npm run lhci:mobile # Run with mobile preset
npm run lhci:desktop # Run with desktop preset
# Individual monitoring tools
npm run bundle:analyze # Analyze bundle sizes and budgets
npm run performance:monitor # Performance metrics and Lighthouse CI
npm run web-vitals:track # Core Web Vitals tracking
# Comprehensive testing
npm run test:performance # All performance tests
npm run monitor:all # All monitoring tools
# Traditional Lighthouse CI
npm run lhci # Run Lighthouse CI
npm run lhci:mobile # Run with mobile preset
npm run lhci:desktop # Run with desktop preset
```
### Performance Monitoring Dashboard
Access the performance monitoring dashboard at `/monitor` to view:
- Real-time Web Vitals metrics
- Historical performance data
- Bundle analysis results
- Performance budget status
- Optimization recommendations
## 🔄 CI/CD Pipeline
### Gitea Actions Workflow
+9
View File
@@ -20,6 +20,12 @@ npm run visual:test
# Performance check
npm run lhci
# Performance monitoring
npm run test:performance # Comprehensive performance testing
npm run bundle:analyze # Bundle size analysis
npm run web-vitals:track # Web Vitals tracking
npm run monitor:all # All monitoring tools
# Storybook tests
npm run test:sb
```
@@ -48,6 +54,9 @@ npx playwright test --headed
- **Visual Regression**: 23 tests per browser ✅
- **Accessibility Tests**: WCAG 2.1 AA compliance ✅
- **Performance Tests**: Lighthouse CI with budgets ✅
- **Bundle Analysis**: Real-time monitoring with budgets ✅
- **Web Vitals Tracking**: Core Web Vitals collection ✅
- **Performance Optimization**: React.memo + code splitting ✅
## 🔧 Common Test Commands
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+77 -1
View File
@@ -5,12 +5,88 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
webpack(config) {
// Performance optimizations
experimental: {
optimizeCss: true,
optimizePackageImports: ["react", "react-dom"],
},
// Compression
compress: true,
// Image optimization
images: {
formats: ["image/webp", "image/avif"],
minimumCacheTTL: 60,
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
// Headers for caching
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
],
},
{
source: "/static/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
webpack(config, { dev, isServer }) {
// SVG handling
config.module.rules.push({
test: /\.svg$/,
issuer: /\.[jt]sx?$/,
use: ["@svgr/webpack"],
});
// Bundle analysis - only in production builds
if (process.env.ANALYZE === "true" && !dev) {
try {
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: "static",
openAnalyzer: false,
reportFilename: isServer
? "../analyze/server.html"
: "../analyze/client.html",
}),
);
} catch (error) {
console.warn("Bundle analyzer not available:", error.message);
}
}
// Production optimizations
if (!dev && !isServer) {
// Tree shaking optimization
config.optimization = {
...config.optimization,
usedExports: true,
sideEffects: false,
};
}
return config;
},
};
+249 -17
View File
@@ -12,6 +12,7 @@
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.2",
"critters": "^0.0.23",
"gray-matter": "^4.0.3",
"next": "15.2.4",
"react": "^19.0.0",
@@ -59,7 +60,9 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"wait-on": "^8.0.4"
"wait-on": "^8.0.4",
"web-vitals": "^4.2.4",
"webpack-bundle-analyzer": "^4.10.1"
}
},
"node_modules/@adobe/css-tools": {
@@ -2210,6 +2213,16 @@
"node": ">=18"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
@@ -8022,6 +8035,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -8097,7 +8123,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -8890,7 +8915,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
@@ -9185,7 +9209,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
@@ -9491,7 +9514,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -9504,7 +9526,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/color-string": {
@@ -9778,6 +9799,95 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/critters": {
"version": "0.0.23",
"resolved": "https://registry.npmjs.org/critters/-/critters-0.0.23.tgz",
"integrity": "sha512-/MCsQbuzTPA/ZTOjjyr2Na5o3lRpr8vd0MZE8tMP0OBNg/VrLxWHteVKalQ8KR+fBmUadbJLdoyEz9sT+q84qg==",
"license": "Apache-2.0",
"dependencies": {
"chalk": "^4.1.0",
"css-select": "^5.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.2",
"htmlparser2": "^8.0.2",
"postcss": "^8.4.23",
"postcss-media-query-parser": "^0.2.3"
}
},
"node_modules/critters/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/critters/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/critters/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/critters/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/critters/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -9814,7 +9924,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
@@ -9831,7 +9940,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@ -9846,7 +9954,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -9859,7 +9966,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -9875,7 +9981,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@@ -9904,7 +10009,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
@@ -10076,6 +10180,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -10576,7 +10687,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -12765,6 +12875,22 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/gzip-size": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
"integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"duplexer": "^0.1.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -12782,7 +12908,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -18108,7 +18233,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
@@ -18553,6 +18677,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
"integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
"dev": true,
"license": "(WTFPL OR MIT)",
"bin": {
"opener": "bin/opener-bin.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -19163,7 +19297,6 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -19188,6 +19321,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-media-query-parser": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
"license": "MIT"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -21534,7 +21673,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
@@ -23006,6 +23144,13 @@
"makeerror": "1.0.12"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz",
@@ -23023,6 +23168,93 @@
"node": ">=12"
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "4.10.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
"integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^7.2.0",
"debounce": "^1.2.1",
"escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0",
"html-escaper": "^2.0.2",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^2.0.3",
"ws": "^7.3.1"
},
"bin": {
"webpack-bundle-analyzer": "lib/bin/analyzer.js"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/webpack-bundle-analyzer/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+13 -2
View File
@@ -33,12 +33,21 @@
"seed-snapshots:local": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts --project=chromium",
"visual:test": "npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:update": "PLAYWRIGHT_UPDATE_SNAPSHOTS=1 npx playwright test tests/e2e/visual-regression.spec.ts",
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui"
"visual:ui": "npx playwright test tests/e2e/visual-regression.spec.ts --ui",
"analyze": "npm run analyze:browser && npm run analyze:server",
"analyze:server": "ANALYZE=true npm run build",
"analyze:browser": "BUNDLE_ANALYZE=true npm run build",
"bundle:analyze": "node scripts/bundle-analyzer.js",
"web-vitals:track": "node scripts/web-vitals-tracker.js",
"monitor:all": "npm run bundle:analyze && npm run performance:monitor && npm run web-vitals:track",
"test:performance": "node scripts/test-performance.js",
"test:performance:ci": "npm run test:performance"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.2",
"critters": "^0.0.23",
"gray-matter": "^4.0.3",
"next": "15.2.4",
"react": "^19.0.0",
@@ -86,6 +95,8 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"wait-on": "^8.0.4"
"wait-on": "^8.0.4",
"web-vitals": "^4.2.4",
"webpack-bundle-analyzer": "^4.10.1"
}
}
+37
View File
@@ -1,4 +1,41 @@
{
"budgets": [
{
"name": "lcp",
"maxValue": 2500,
"description": "Largest Contentful Paint should be under 2.5s"
},
{
"name": "fid",
"maxValue": 100,
"description": "First Input Delay should be under 100ms"
},
{
"name": "cls",
"maxValue": 0.1,
"description": "Cumulative Layout Shift should be under 0.1"
},
{
"name": "fcp",
"maxValue": 1800,
"description": "First Contentful Paint should be under 1.8s"
},
{
"name": "ttfb",
"maxValue": 800,
"description": "Time to First Byte should be under 800ms"
},
{
"name": "bundle-size",
"maxSizeKB": 500,
"description": "Individual bundle size should be under 500KB"
},
{
"name": "total-size",
"maxSizeKB": 2000,
"description": "Total bundle size should be under 2MB"
}
],
"performance": {
"budgets": [
{
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
/**
* Bundle Analysis Script
* Analyzes webpack bundles and provides detailed performance insights
*/
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const BUNDLE_ANALYSIS_DIR = path.join(__dirname, "..", ".next", "analyze");
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
class BundleAnalyzer {
constructor() {
this.results = {
timestamp: new Date().toISOString(),
bundles: {},
recommendations: [],
budgetViolations: [],
};
}
/**
* Run bundle analysis using build output
*/
async analyzeBundles() {
console.log("🔍 Starting bundle analysis...");
try {
// Ensure analyze directory exists
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
}
// Build the project first
console.log("🏗️ Building project...");
execSync("npm run build", { stdio: "inherit" });
// Parse bundle stats from build output
await this.parseBundleStats();
// Check performance budgets
this.checkPerformanceBudgets();
// Generate recommendations
this.generateRecommendations();
// Save results
this.saveResults();
console.log("✅ Bundle analysis complete!");
console.log(`📁 Results saved to: ${BUNDLE_ANALYSIS_DIR}`);
} catch (error) {
console.error("❌ Bundle analysis failed:", error.message);
process.exit(1);
}
}
/**
* Parse bundle statistics from build output
*/
async parseBundleStats() {
const staticPath = path.join(__dirname, "..", ".next", "static");
const chunksPath = path.join(staticPath, "chunks");
// Analyze static assets
if (fs.existsSync(staticPath)) {
this.analyzeDirectory(staticPath, "static");
}
// Analyze chunks
if (fs.existsSync(chunksPath)) {
this.analyzeDirectory(chunksPath, "chunks");
}
// Analyze pages
const pagesPath = path.join(__dirname, "..", ".next", "server", "pages");
if (fs.existsSync(pagesPath)) {
this.analyzeDirectory(pagesPath, "pages");
}
}
/**
* Analyze directory for bundle sizes
*/
analyzeDirectory(dirPath, type) {
const files = fs.readdirSync(dirPath);
files.forEach((file) => {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (stats.isFile() && (file.endsWith(".js") || file.endsWith(".css"))) {
const key = `${type}/${file}`;
this.results.bundles[key] = {
size: stats.size,
sizeKB: Math.round(stats.size / 1024),
lastModified: stats.mtime,
type: file.endsWith(".css") ? "css" : "js",
};
} else if (stats.isDirectory()) {
this.analyzeDirectory(filePath, `${type}/${file}`);
}
});
}
/**
* Check against performance budgets
*/
checkPerformanceBudgets() {
const budgets = PERFORMANCE_BUDGETS.budgets || [];
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
const budget = budgets.find(
(b) => filename.includes(b.name) || b.name === "all",
);
if (budget) {
if (bundle.sizeKB > budget.maxSizeKB) {
this.results.budgetViolations.push({
file: filename,
currentSize: bundle.sizeKB,
maxSize: budget.maxSizeKB,
overage: bundle.sizeKB - budget.maxSizeKB,
severity:
bundle.sizeKB > budget.maxSizeKB * 1.2 ? "high" : "medium",
});
}
} else {
// Default budget check for large files
if (bundle.sizeKB > 500) {
this.results.budgetViolations.push({
file: filename,
currentSize: bundle.sizeKB,
maxSize: 500,
overage: bundle.sizeKB - 500,
severity: bundle.sizeKB > 600 ? "high" : "medium",
});
}
}
});
}
/**
* Generate optimization recommendations
*/
generateRecommendations() {
const recommendations = [];
// Check for large bundles
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
if (bundle.sizeKB > 500) {
recommendations.push({
type: "large-bundle",
file: filename,
size: bundle.sizeKB,
suggestion:
"Consider code splitting or dynamic imports for this bundle",
});
}
});
// Check for budget violations
if (this.results.budgetViolations.length > 0) {
recommendations.push({
type: "budget-violation",
count: this.results.budgetViolations.length,
suggestion:
"Review and optimize bundles that exceed performance budgets",
});
}
// General recommendations
const totalSize = Object.values(this.results.bundles).reduce(
(sum, bundle) => sum + bundle.sizeKB,
0,
);
if (totalSize > 2000) {
recommendations.push({
type: "total-size",
size: totalSize,
suggestion: "Consider implementing more aggressive code splitting",
});
}
this.results.recommendations = recommendations;
}
/**
* Save analysis results
*/
saveResults() {
// Ensure directory exists
if (!fs.existsSync(BUNDLE_ANALYSIS_DIR)) {
fs.mkdirSync(BUNDLE_ANALYSIS_DIR, { recursive: true });
}
const resultsPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-analysis.json");
fs.writeFileSync(resultsPath, JSON.stringify(this.results, null, 2));
// Generate markdown report
this.generateMarkdownReport();
}
/**
* Generate markdown report
*/
generateMarkdownReport() {
const reportPath = path.join(BUNDLE_ANALYSIS_DIR, "bundle-report.md");
let report = `# Bundle Analysis Report\n\n`;
report += `**Generated:** ${this.results.timestamp}\n\n`;
// Bundle sizes
report += `## Bundle Sizes\n\n`;
report += `| File | Size (KB) | Status |\n`;
report += `|------|-----------|--------|\n`;
Object.entries(this.results.bundles).forEach(([filename, bundle]) => {
const status = bundle.sizeKB > 500 ? "⚠️ Large" : "✅ Good";
report += `| ${filename} | ${bundle.sizeKB} | ${status} |\n`;
});
// Budget violations
if (this.results.budgetViolations.length > 0) {
report += `\n## Budget Violations\n\n`;
this.results.budgetViolations.forEach((violation) => {
report += `- **${violation.file}**: ${violation.currentSize}KB (exceeds ${violation.maxSize}KB by ${violation.overage}KB)\n`;
});
}
// Recommendations
if (this.results.recommendations.length > 0) {
report += `\n## Recommendations\n\n`;
this.results.recommendations.forEach((rec) => {
report += `- ${rec.suggestion}\n`;
});
}
fs.writeFileSync(reportPath, report);
}
}
// Run analysis if called directly
if (require.main === module) {
const analyzer = new BundleAnalyzer();
analyzer.analyzeBundles().catch(console.error);
}
module.exports = BundleAnalyzer;
+265 -354
View File
@@ -2,386 +2,297 @@
/**
* Performance Monitoring Script
*
* This script provides comprehensive performance monitoring capabilities
* for the Community Rule application.
* Monitors Core Web Vitals and performance metrics
*/
const { spawn } = require("child_process");
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
// Performance budgets
const PERFORMANCE_BUDGETS = {
page_load_time: 3000,
first_contentful_paint: 2000,
largest_contentful_paint: 2500,
first_input_delay: 100,
dns_lookup: 100,
tcp_connection: 200,
ttfb: 600,
dom_content_loaded: 1500,
full_load: 3000,
component_render_time: 500,
interaction_time: 100,
scroll_performance: 50,
network_request_duration: 1000,
memory_usage_mb: 50,
};
const PERFORMANCE_BUDGETS = require("../performance-budgets.json");
const MONITORING_DIR = path.join(__dirname, "..", ".next", "monitoring");
// Baseline metrics for regression detection
const BASELINE_METRICS = {
page_load_time: 2000,
first_contentful_paint: 1500,
largest_contentful_paint: 2000,
first_input_delay: 50,
dns_lookup: 50,
tcp_connection: 100,
ttfb: 400,
dom_content_loaded: 1000,
full_load: 2000,
component_render_time: 300,
interaction_time: 50,
scroll_performance: 30,
network_request_duration: 500,
memory_usage_mb: 30,
};
class PerformanceMonitorScript {
class PerformanceMonitor {
constructor() {
this.metrics = new Map();
this.regressions = [];
this.warnings = [];
}
/**
* Run Lighthouse CI performance tests
*/
async runLighthouseCI() {
console.log("🚀 Running Lighthouse CI performance tests...");
return new Promise((resolve, reject) => {
const lhci = spawn("npx", ["lhci", "autorun"], {
stdio: "pipe",
shell: true,
});
let output = "";
let errorOutput = "";
lhci.stdout.on("data", (data) => {
output += data.toString();
console.log(data.toString());
});
lhci.stderr.on("data", (data) => {
errorOutput += data.toString();
console.error(data.toString());
});
lhci.on("close", (code) => {
if (code === 0) {
console.log("✅ Lighthouse CI tests completed successfully");
this.analyzeLighthouseResults(output);
resolve(output);
} else {
console.error("❌ Lighthouse CI tests failed");
reject(
new Error(`Lighthouse CI failed with code ${code}: ${errorOutput}`),
);
}
});
});
}
/**
* Run Playwright performance tests
*/
async runPlaywrightPerformanceTests() {
console.log("🎭 Running Playwright performance tests...");
return new Promise((resolve, reject) => {
const playwright = spawn(
"npx",
[
"playwright",
"test",
"tests/e2e/performance.spec.ts",
"--reporter=json",
],
{
stdio: "pipe",
shell: true,
},
);
let output = "";
let errorOutput = "";
playwright.stdout.on("data", (data) => {
output += data.toString();
});
playwright.stderr.on("data", (data) => {
errorOutput += data.toString();
});
playwright.on("close", (code) => {
if (code === 0) {
console.log("✅ Playwright performance tests completed successfully");
this.analyzePlaywrightResults(output);
resolve(output);
} else {
console.error("❌ Playwright performance tests failed");
reject(
new Error(
`Playwright tests failed with code ${code}: ${errorOutput}`,
),
);
}
});
});
}
/**
* Analyze Lighthouse CI results
*/
analyzeLighthouseResults(output) {
console.log("📊 Analyzing Lighthouse CI results...");
// Parse Lighthouse results
const lines = output.split("\n");
let currentMetric = null;
for (const line of lines) {
if (line.includes("Performance")) {
const scoreMatch = line.match(/(\d+)/);
if (scoreMatch) {
const score = parseInt(scoreMatch[1]);
this.recordMetric("lighthouse_performance_score", score);
if (score < 90) {
this.warnings.push(
`Performance score below threshold: ${score}/100`,
);
}
}
}
if (line.includes("First Contentful Paint")) {
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
if (timeMatch) {
const time = parseFloat(timeMatch[1]);
this.recordMetric("first_contentful_paint", time);
if (time > PERFORMANCE_BUDGETS.first_contentful_paint) {
this.warnings.push(
`First Contentful Paint exceeded budget: ${time}ms`,
);
}
}
}
if (line.includes("Largest Contentful Paint")) {
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
if (timeMatch) {
const time = parseFloat(timeMatch[1]);
this.recordMetric("largest_contentful_paint", time);
if (time > PERFORMANCE_BUDGETS.largest_contentful_paint) {
this.warnings.push(
`Largest Contentful Paint exceeded budget: ${time}ms`,
);
}
}
}
if (line.includes("Total Blocking Time")) {
const timeMatch = line.match(/(\d+(?:\.\d+)?)\s*ms/);
if (timeMatch) {
const time = parseFloat(timeMatch[1]);
this.recordMetric("total_blocking_time", time);
if (time > 300) {
this.warnings.push(
`Total Blocking Time exceeded budget: ${time}ms`,
);
}
}
}
if (line.includes("Cumulative Layout Shift")) {
const shiftMatch = line.match(/(\d+(?:\.\d+)?)/);
if (shiftMatch) {
const shift = parseFloat(shiftMatch[1]);
this.recordMetric("cumulative_layout_shift", shift);
if (shift > 0.1) {
this.warnings.push(
`Cumulative Layout Shift exceeded budget: ${shift}`,
);
}
}
}
}
}
/**
* Analyze Playwright test results
*/
analyzePlaywrightResults(output) {
console.log("📊 Analyzing Playwright test results...");
try {
const results = JSON.parse(output);
for (const result of results) {
if (result.status === "failed") {
this.warnings.push(`Test failed: ${result.title}`);
}
}
} catch (error) {
console.warn("Could not parse Playwright results as JSON");
}
}
/**
* Record a performance metric
*/
recordMetric(name, value) {
if (!this.metrics.has(name)) {
this.metrics.set(name, []);
}
this.metrics.get(name).push({
value,
timestamp: Date.now(),
});
// Check against baseline for regression detection
const baseline = BASELINE_METRICS[name];
if (baseline) {
const regressionThreshold = baseline * 1.2; // 20% regression threshold
if (value > regressionThreshold) {
this.regressions.push({
metric: name,
current: value,
baseline,
regression: (((value - baseline) / baseline) * 100).toFixed(1) + "%",
});
}
}
}
/**
* Generate performance report
*/
generateReport() {
console.log("\n📈 Performance Monitoring Report");
console.log("================================\n");
// Summary
console.log("📊 Summary:");
console.log(`- Total metrics recorded: ${this.metrics.size}`);
console.log(
`- Performance regressions detected: ${this.regressions.length}`,
);
console.log(`- Warnings: ${this.warnings.length}\n`);
// Performance regressions
if (this.regressions.length > 0) {
console.log("🚨 Performance Regressions:");
for (const regression of this.regressions) {
console.log(
` - ${regression.metric}: ${regression.current} (baseline: ${regression.baseline}, regression: ${regression.regression})`,
);
}
console.log("");
}
// Warnings
if (this.warnings.length > 0) {
console.log("⚠️ Warnings:");
for (const warning of this.warnings) {
console.log(` - ${warning}`);
}
console.log("");
}
// Metrics summary
console.log("📋 Metrics Summary:");
for (const [name, values] of this.metrics) {
const latest = values[values.length - 1];
const average =
values.reduce((sum, v) => sum + v.value, 0) / values.length;
const budget = PERFORMANCE_BUDGETS[name];
console.log(` - ${name}:`);
console.log(` Latest: ${latest.value}`);
console.log(` Average: ${average.toFixed(2)}`);
if (budget) {
const status = latest.value <= budget ? "✅" : "❌";
console.log(` Budget: ${budget} ${status}`);
}
}
// Save report to file
const report = {
this.metrics = {
timestamp: new Date().toISOString(),
summary: {
totalMetrics: this.metrics.size,
regressions: this.regressions.length,
warnings: this.warnings.length,
},
regressions: this.regressions,
warnings: this.warnings,
metrics: Object.fromEntries(this.metrics),
coreWebVitals: {},
bundleMetrics: {},
recommendations: [],
};
const reportPath = path.join(__dirname, "../performance-report.json");
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\n📄 Report saved to: ${reportPath}`);
return report;
}
/**
* Run all performance monitoring
* Run comprehensive performance monitoring
*/
async run() {
console.log("🔍 Starting Performance Monitoring...\n");
async monitorPerformance() {
console.log("📊 Starting performance monitoring...");
try {
// Run Lighthouse CI tests
// Ensure monitoring directory exists
if (!fs.existsSync(MONITORING_DIR)) {
fs.mkdirSync(MONITORING_DIR, { recursive: true });
}
// Run Lighthouse CI for Core Web Vitals
await this.runLighthouseCI();
// Run Playwright performance tests
await this.runPlaywrightPerformanceTests();
// Analyze bundle performance
await this.analyzeBundlePerformance();
// Generate and display report
const report = this.generateReport();
// Check performance budgets
this.checkPerformanceBudgets();
// Exit with appropriate code
if (this.regressions.length > 0) {
console.log("❌ Performance regressions detected!");
process.exit(1);
} else if (this.warnings.length > 0) {
console.log("⚠️ Performance warnings detected.");
process.exit(0);
} else {
console.log("✅ All performance checks passed!");
process.exit(0);
}
// Generate performance report
this.generatePerformanceReport();
console.log("✅ Performance monitoring complete!");
console.log(`📁 Results saved to: ${MONITORING_DIR}`);
} catch (error) {
console.error("❌ Performance monitoring failed:", error.message);
process.exit(1);
}
}
/**
* Run Lighthouse CI for Core Web Vitals
*/
async runLighthouseCI() {
console.log("🔍 Running Lighthouse CI...");
try {
// Check if server is running
const { execSync } = require("child_process");
try {
execSync("curl -s http://localhost:3000 > /dev/null", {
stdio: "pipe",
});
} catch (error) {
console.warn(
"⚠️ Development server not running, skipping Lighthouse CI...",
);
return;
}
// Run Lighthouse CI with performance focus
execSync("npx lhci autorun --collect.url=http://localhost:3000", {
stdio: "inherit",
cwd: path.join(__dirname, ".."),
});
// Parse Lighthouse results
await this.parseLighthouseResults();
} catch (error) {
console.warn("⚠️ Lighthouse CI failed, continuing with other metrics...");
}
}
/**
* Parse Lighthouse CI results
*/
async parseLighthouseResults() {
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
if (fs.existsSync(lhciResultsPath)) {
const files = fs.readdirSync(lhciResultsPath);
const resultFile = files.find((f) => f.endsWith(".json"));
if (resultFile) {
const results = JSON.parse(
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
);
if (results.lhr && results.lhr.audits) {
this.metrics.coreWebVitals = {
lcp: this.getAuditScore(
results.lhr.audits,
"largest-contentful-paint",
),
fid: this.getAuditScore(results.lhr.audits, "max-potential-fid"),
cls: this.getAuditScore(
results.lhr.audits,
"cumulative-layout-shift",
),
fcp: this.getAuditScore(
results.lhr.audits,
"first-contentful-paint",
),
tti: this.getAuditScore(results.lhr.audits, "interactive"),
performance: results.lhr.categories.performance?.score * 100 || 0,
};
}
}
}
}
/**
* Get audit score from Lighthouse results
*/
getAuditScore(audits, auditId) {
const audit = audits[auditId];
if (!audit) return null;
return {
score: audit.score * 100,
value: audit.numericValue,
displayValue: audit.displayValue,
};
}
/**
* Analyze bundle performance
*/
async analyzeBundlePerformance() {
console.log("📦 Analyzing bundle performance...");
const bundleStatsPath = path.join(
__dirname,
"..",
".next",
"static",
"chunks",
);
if (fs.existsSync(bundleStatsPath)) {
const files = fs.readdirSync(bundleStatsPath);
let totalSize = 0;
let jsFiles = 0;
files.forEach((file) => {
if (file.endsWith(".js")) {
const filePath = path.join(bundleStatsPath, file);
const stats = fs.statSync(filePath);
totalSize += stats.size;
jsFiles++;
}
});
this.metrics.bundleMetrics = {
totalSizeKB: Math.round(totalSize / 1024),
totalSizeMB: Math.round((totalSize / (1024 * 1024)) * 100) / 100,
fileCount: jsFiles,
averageSizeKB: Math.round(totalSize / jsFiles / 1024),
};
}
}
/**
* Check performance budgets
*/
checkPerformanceBudgets() {
const budgets = PERFORMANCE_BUDGETS.budgets;
const violations = [];
// Check Core Web Vitals
if (this.metrics.coreWebVitals.lcp) {
const lcpValue = this.metrics.coreWebVitals.lcp.value;
const lcpBudget = budgets.find((b) => b.name === "lcp")?.maxValue;
if (lcpBudget && lcpValue > lcpBudget) {
violations.push({
metric: "LCP",
current: lcpValue,
budget: lcpBudget,
severity: lcpValue > lcpBudget * 1.5 ? "high" : "medium",
});
}
}
// Check bundle size
if (this.metrics.bundleMetrics.totalSizeKB > 2000) {
violations.push({
metric: "Bundle Size",
current: this.metrics.bundleMetrics.totalSizeKB,
budget: 2000,
severity: "medium",
});
}
this.metrics.budgetViolations = violations;
}
/**
* Generate performance report
*/
generatePerformanceReport() {
const reportPath = path.join(MONITORING_DIR, "performance-report.json");
fs.writeFileSync(reportPath, JSON.stringify(this.metrics, null, 2));
// Generate markdown report
this.generateMarkdownReport();
}
/**
* Generate markdown performance report
*/
generateMarkdownReport() {
const reportPath = path.join(MONITORING_DIR, "performance-report.md");
let report = `# Performance Monitoring Report\n\n`;
report += `**Generated:** ${this.metrics.timestamp}\n\n`;
// Core Web Vitals
if (Object.keys(this.metrics.coreWebVitals).length > 0) {
report += `## Core Web Vitals\n\n`;
report += `| Metric | Score | Value | Status |\n`;
report += `|--------|-------|-------|--------|\n`;
Object.entries(this.metrics.coreWebVitals).forEach(([metric, data]) => {
if (data && typeof data === "object" && data.score !== undefined) {
const status = this.getMetricStatus(metric, data.score);
report += `| ${metric.toUpperCase()} | ${data.score} | ${
data.displayValue || "N/A"
} | ${status} |\n`;
}
});
}
// Bundle Metrics
if (Object.keys(this.metrics.bundleMetrics).length > 0) {
report += `\n## Bundle Metrics\n\n`;
report += `- **Total Size:** ${this.metrics.bundleMetrics.totalSizeMB}MB (${this.metrics.bundleMetrics.totalSizeKB}KB)\n`;
report += `- **File Count:** ${this.metrics.bundleMetrics.fileCount}\n`;
report += `- **Average Size:** ${this.metrics.bundleMetrics.averageSizeKB}KB per file\n`;
}
// Budget Violations
if (
this.metrics.budgetViolations &&
this.metrics.budgetViolations.length > 0
) {
report += `\n## Budget Violations\n\n`;
this.metrics.budgetViolations.forEach((violation) => {
report += `- **${violation.metric}**: ${violation.current} (exceeds ${
violation.budget
}) - ${violation.severity.toUpperCase()}\n`;
});
}
// Recommendations
report += `\n## Recommendations\n\n`;
report += `- Monitor Core Web Vitals regularly\n`;
report += `- Implement code splitting for large bundles\n`;
report += `- Use dynamic imports for non-critical components\n`;
report += `- Optimize images and fonts\n`;
report += `- Enable compression and caching\n`;
fs.writeFileSync(reportPath, report);
}
/**
* Get status emoji for metric score
*/
getMetricStatus(metric, score) {
if (score >= 90) return "✅ Good";
if (score >= 50) return "⚠️ Needs Improvement";
return "❌ Poor";
}
}
// Run the performance monitor if this script is executed directly
// Run monitoring if called directly
if (require.main === module) {
const monitor = new PerformanceMonitorScript();
monitor.run();
const monitor = new PerformanceMonitor();
monitor.monitorPerformance().catch(console.error);
}
module.exports = PerformanceMonitorScript;
module.exports = PerformanceMonitor;
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* Comprehensive Performance Testing Script
* Integrates bundle analysis, performance monitoring, and Web Vitals tracking
*/
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const TEST_RESULTS_DIR = path.join(__dirname, "..", ".next", "test-results");
class PerformanceTester {
constructor() {
this.results = {
timestamp: new Date().toISOString(),
bundleAnalysis: {},
performanceMonitoring: {},
webVitals: {},
lighthouse: {},
summary: {
passed: 0,
failed: 0,
warnings: 0,
total: 0,
},
};
}
/**
* Run comprehensive performance testing
*/
async runTests() {
console.log("🧪 Starting comprehensive performance testing...");
try {
// Ensure test results directory exists
if (!fs.existsSync(TEST_RESULTS_DIR)) {
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
}
// 1. Bundle Analysis
console.log("📊 Running bundle analysis...");
await this.runBundleAnalysis();
// 2. Performance Monitoring
console.log("📈 Running performance monitoring...");
await this.runPerformanceMonitoring();
// 3. Web Vitals Tracking
console.log("📊 Setting up Web Vitals tracking...");
await this.runWebVitalsTracking();
// 4. Lighthouse CI (if server is available)
console.log("🔍 Running Lighthouse CI...");
await this.runLighthouseCI();
// 5. Generate comprehensive report
this.generateComprehensiveReport();
console.log("✅ Performance testing complete!");
console.log(`📁 Results saved to: ${TEST_RESULTS_DIR}`);
// Return exit code based on results
const hasFailures = this.results.summary.failed > 0;
if (hasFailures) {
console.log("❌ Performance tests failed");
process.exit(1);
} else {
console.log("✅ All performance tests passed");
process.exit(0);
}
} catch (error) {
console.error("❌ Performance testing failed:", error.message);
process.exit(1);
}
}
/**
* Run bundle analysis
*/
async runBundleAnalysis() {
try {
execSync("npm run bundle:analyze", { stdio: "inherit" });
// Parse bundle analysis results
const bundleReportPath = path.join(
__dirname,
"..",
".next",
"analyze",
"bundle-analysis.json",
);
if (fs.existsSync(bundleReportPath)) {
const bundleData = JSON.parse(
fs.readFileSync(bundleReportPath, "utf8"),
);
this.results.bundleAnalysis = bundleData;
// Check for budget violations
if (
bundleData.budgetViolations &&
bundleData.budgetViolations.length > 0
) {
this.results.summary.failed += bundleData.budgetViolations.length;
console.log(
`⚠️ Found ${bundleData.budgetViolations.length} budget violations`,
);
} else {
this.results.summary.passed += 1;
console.log("✅ Bundle analysis passed");
}
}
this.results.summary.total += 1;
} catch (error) {
console.error("❌ Bundle analysis failed:", error.message);
this.results.summary.failed += 1;
this.results.summary.total += 1;
}
}
/**
* Run performance monitoring
*/
async runPerformanceMonitoring() {
try {
execSync("npm run performance:monitor", { stdio: "inherit" });
// Parse performance monitoring results
const perfReportPath = path.join(
__dirname,
"..",
".next",
"monitoring",
"performance-report.json",
);
if (fs.existsSync(perfReportPath)) {
const perfData = JSON.parse(fs.readFileSync(perfReportPath, "utf8"));
this.results.performanceMonitoring = perfData;
// Check for budget violations
if (perfData.budgetViolations && perfData.budgetViolations.length > 0) {
this.results.summary.failed += perfData.budgetViolations.length;
console.log(
`⚠️ Found ${perfData.budgetViolations.length} performance violations`,
);
} else {
this.results.summary.passed += 1;
console.log("✅ Performance monitoring passed");
}
}
this.results.summary.total += 1;
} catch (error) {
console.error("❌ Performance monitoring failed:", error.message);
this.results.summary.failed += 1;
this.results.summary.total += 1;
}
}
/**
* Run Web Vitals tracking
*/
async runWebVitalsTracking() {
try {
execSync("npm run web-vitals:track", { stdio: "inherit" });
// Parse Web Vitals results
const vitalsReportPath = path.join(
__dirname,
"..",
".next",
"web-vitals",
"report.json",
);
if (fs.existsSync(vitalsReportPath)) {
const vitalsData = JSON.parse(
fs.readFileSync(vitalsReportPath, "utf8"),
);
this.results.webVitals = vitalsData;
console.log("✅ Web Vitals tracking setup complete");
}
this.results.summary.passed += 1;
this.results.summary.total += 1;
} catch (error) {
console.error("❌ Web Vitals tracking failed:", error.message);
this.results.summary.failed += 1;
this.results.summary.total += 1;
}
}
/**
* Run Lighthouse CI
*/
async runLighthouseCI() {
try {
// Check if server is running
try {
execSync("curl -s http://localhost:3000 > /dev/null", {
stdio: "pipe",
});
} catch (error) {
console.warn(
"⚠️ Development server not running, skipping Lighthouse CI...",
);
this.results.summary.warnings += 1;
this.results.summary.total += 1;
return;
}
execSync("npm run lhci", { stdio: "inherit" });
// Parse Lighthouse results
const lhciResultsPath = path.join(__dirname, "..", ".lighthouseci");
if (fs.existsSync(lhciResultsPath)) {
const files = fs.readdirSync(lhciResultsPath);
const resultFile = files.find((f) => f.endsWith(".json"));
if (resultFile) {
const lhciData = JSON.parse(
fs.readFileSync(path.join(lhciResultsPath, resultFile), "utf8"),
);
this.results.lighthouse = lhciData;
console.log("✅ Lighthouse CI completed");
}
}
this.results.summary.passed += 1;
this.results.summary.total += 1;
} catch (error) {
console.warn("⚠️ Lighthouse CI failed:", error.message);
this.results.summary.warnings += 1;
this.results.summary.total += 1;
}
}
/**
* Generate comprehensive test report
*/
generateComprehensiveReport() {
// Ensure test results directory exists
if (!fs.existsSync(TEST_RESULTS_DIR)) {
fs.mkdirSync(TEST_RESULTS_DIR, { recursive: true });
}
const reportPath = path.join(
TEST_RESULTS_DIR,
"performance-test-report.json",
);
fs.writeFileSync(reportPath, JSON.stringify(this.results, null, 2));
// Generate markdown report
this.generateMarkdownReport();
}
/**
* Generate markdown test report
*/
generateMarkdownReport() {
const reportPath = path.join(
TEST_RESULTS_DIR,
"performance-test-report.md",
);
let report = `# Performance Test Report\n\n`;
report += `**Generated:** ${this.results.timestamp}\n\n`;
// Summary
report += `## Test Summary\n\n`;
report += `- **Total Tests:** ${this.results.summary.total}\n`;
report += `- **Passed:** ${this.results.summary.passed}\n`;
report += `- **Failed:** ${this.results.summary.failed}\n`;
report += `- **Warnings:** ${this.results.summary.warnings} ⚠️\n\n`;
// Bundle Analysis Results
if (Object.keys(this.results.bundleAnalysis).length > 0) {
report += `## Bundle Analysis\n\n`;
if (
this.results.bundleAnalysis.budgetViolations &&
this.results.bundleAnalysis.budgetViolations.length > 0
) {
report += `### Budget Violations\n\n`;
this.results.bundleAnalysis.budgetViolations.forEach((violation) => {
report += `- **${violation.file}**: ${
violation.currentSize
}KB (exceeds ${violation.maxSize}KB by ${
violation.overage
}KB) - ${violation.severity.toUpperCase()}\n`;
});
} else {
report += `✅ No bundle budget violations found\n\n`;
}
}
// Performance Monitoring Results
if (Object.keys(this.results.performanceMonitoring).length > 0) {
report += `## Performance Monitoring\n\n`;
if (
this.results.performanceMonitoring.budgetViolations &&
this.results.performanceMonitoring.budgetViolations.length > 0
) {
report += `### Budget Violations\n\n`;
this.results.performanceMonitoring.budgetViolations.forEach(
(violation) => {
report += `- **${violation.metric}**: ${
violation.current
} (exceeds ${
violation.budget
}) - ${violation.severity.toUpperCase()}\n`;
},
);
} else {
report += `✅ No performance budget violations found\n\n`;
}
}
// Web Vitals Results
if (Object.keys(this.results.webVitals).length > 0) {
report += `## Web Vitals Tracking\n\n`;
report += `✅ Web Vitals tracking setup complete\n\n`;
}
// Lighthouse Results
if (Object.keys(this.results.lighthouse).length > 0) {
report += `## Lighthouse CI\n\n`;
report += `✅ Lighthouse CI completed successfully\n\n`;
}
// Recommendations
report += `## Recommendations\n\n`;
report += `- Monitor bundle sizes regularly\n`;
report += `- Track Core Web Vitals in production\n`;
report += `- Run performance tests in CI/CD pipeline\n`;
report += `- Set up performance budgets and alerts\n`;
fs.writeFileSync(reportPath, report);
}
}
// Run if called directly
if (require.main === module) {
const tester = new PerformanceTester();
tester.runTests().catch(console.error);
}
module.exports = PerformanceTester;
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
/**
* Web Vitals Tracker
* Real-time monitoring of Core Web Vitals in production
*/
const fs = require("fs");
const path = require("path");
const WEB_VITALS_DIR = path.join(__dirname, "..", ".next", "web-vitals");
class WebVitalsTracker {
constructor() {
this.metrics = {
timestamp: new Date().toISOString(),
vitals: {
lcp: [],
fid: [],
cls: [],
fcp: [],
ttfb: [],
},
summary: {},
};
}
/**
* Track Web Vitals from client-side
*/
trackWebVitals() {
const trackingCode = `
// Web Vitals Tracking Script
(function() {
// Import web-vitals library
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
const vitals = {};
// Track Largest Contentful Paint
getLCP((metric) => {
vitals.lcp = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
timestamp: Date.now()
};
sendVitals('lcp', vitals.lcp);
});
// Track First Input Delay
getFID((metric) => {
vitals.fid = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
timestamp: Date.now()
};
sendVitals('fid', vitals.fid);
});
// Track Cumulative Layout Shift
getCLS((metric) => {
vitals.cls = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
timestamp: Date.now()
};
sendVitals('cls', vitals.cls);
});
// Track First Contentful Paint
getFCP((metric) => {
vitals.fcp = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
timestamp: Date.now()
};
sendVitals('fcp', vitals.fcp);
});
// Track Time to First Byte
getTTFB((metric) => {
vitals.ttfb = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
timestamp: Date.now()
};
sendVitals('ttfb', vitals.ttfb);
});
});
// Send vitals to server
function sendVitals(metric, data) {
if (typeof window !== 'undefined' && window.fetch) {
fetch('/api/web-vitals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
metric,
data,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now()
})
}).catch(console.error);
}
}
})();
`;
return trackingCode;
}
/**
* Create API endpoint for receiving Web Vitals
*/
createAPIEndpoint() {
const apiCode = `
// API endpoint for Web Vitals tracking
export default function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { metric, data, url, userAgent, timestamp } = req.body;
// Store the metric data
const vitalsData = {
metric,
data,
url,
userAgent,
timestamp: new Date(timestamp).toISOString()
};
// In production, you would save this to a database
// For now, we'll log it
console.log('Web Vital received:', vitalsData);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error processing web vital:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
`;
return apiCode;
}
/**
* Generate Web Vitals dashboard
*/
generateDashboard() {
const dashboardCode = `
import React, { useState, useEffect } from 'react';
const WebVitalsDashboard = () => {
const [vitals, setVitals] = useState({
lcp: { value: 0, rating: 'unknown' },
fid: { value: 0, rating: 'unknown' },
cls: { value: 0, rating: 'unknown' },
fcp: { value: 0, rating: 'unknown' },
ttfb: { value: 0, rating: 'unknown' }
});
useEffect(() => {
// In a real implementation, you would fetch from your database
// For now, we'll use localStorage for demo purposes
const storedVitals = localStorage.getItem('web-vitals');
if (storedVitals) {
setVitals(JSON.parse(storedVitals));
}
}, []);
const getRatingColor = (rating) => {
switch (rating) {
case 'good': return 'text-green-600';
case 'needs-improvement': return 'text-yellow-600';
case 'poor': return 'text-red-600';
default: return 'text-gray-600';
}
};
const getRatingIcon = (rating) => {
switch (rating) {
case 'good': return '✅';
case 'needs-improvement': return '⚠️';
case 'poor': return '❌';
default: return '❓';
}
};
return (
<div className="p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6">Web Vitals Dashboard</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(vitals).map(([metric, data]) => (
<div key={metric} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-lg">{metric.toUpperCase()}</h3>
<span className="text-2xl">{getRatingIcon(data.rating)}</span>
</div>
<div className="text-sm text-gray-600">
<div>Value: {data.value}ms</div>
<div className={\`font-medium \${getRatingColor(data.rating)}\`}>
Rating: {data.rating.replace('-', ' ')}
</div>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h3 className="font-semibold mb-2">Performance Guidelines</h3>
<ul className="text-sm space-y-1">
<li> <strong>LCP:</strong> Good &lt; 2.5s, Needs Improvement 2.5-4s, Poor &gt; 4s</li>
<li> <strong>FID:</strong> Good &lt; 100ms, Needs Improvement 100-300ms, Poor &gt; 300ms</li>
<li> <strong>CLS:</strong> Good &lt; 0.1, Needs Improvement 0.1-0.25, Poor &gt; 0.25</li>
<li> <strong>FCP:</strong> Good &lt; 1.8s, Needs Improvement 1.8-3s, Poor &gt; 3s</li>
<li> <strong>TTFB:</strong> Good &lt; 800ms, Needs Improvement 800-1800ms, Poor &gt; 1800ms</li>
</ul>
</div>
</div>
);
};
export default WebVitalsDashboard;
`;
return dashboardCode;
}
/**
* Save Web Vitals data
*/
saveVitalsData(metric, data) {
if (!fs.existsSync(WEB_VITALS_DIR)) {
fs.mkdirSync(WEB_VITALS_DIR, { recursive: true });
}
const filePath = path.join(WEB_VITALS_DIR, `${metric}.json`);
let existingData = [];
if (fs.existsSync(filePath)) {
try {
existingData = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
console.warn("Could not parse existing vitals data:", error.message);
}
}
existingData.push({
...data,
timestamp: new Date().toISOString(),
});
// Keep only last 100 entries
if (existingData.length > 100) {
existingData = existingData.slice(-100);
}
fs.writeFileSync(filePath, JSON.stringify(existingData, null, 2));
}
/**
* Generate Web Vitals report
*/
generateReport() {
if (!fs.existsSync(WEB_VITALS_DIR)) {
console.log("No Web Vitals data found");
return;
}
const files = fs.readdirSync(WEB_VITALS_DIR);
const report = {
timestamp: new Date().toISOString(),
metrics: {},
};
files.forEach((file) => {
if (file.endsWith(".json")) {
const metric = file.replace(".json", "");
const data = JSON.parse(
fs.readFileSync(path.join(WEB_VITALS_DIR, file), "utf8"),
);
if (data.length > 0) {
const values = data
.map((d) => d.value)
.filter((v) => v !== undefined);
const ratings = data
.map((d) => d.rating)
.filter((r) => r !== undefined);
report.metrics[metric] = {
count: data.length,
average:
values.length > 0
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
: 0,
min: values.length > 0 ? Math.min(...values) : 0,
max: values.length > 0 ? Math.max(...values) : 0,
goodCount: ratings.filter((r) => r === "good").length,
needsImprovementCount: ratings.filter(
(r) => r === "needs-improvement",
).length,
poorCount: ratings.filter((r) => r === "poor").length,
};
}
}
});
const reportPath = path.join(WEB_VITALS_DIR, "report.json");
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log("📊 Web Vitals report generated:", reportPath);
return report;
}
}
// Run if called directly
if (require.main === module) {
const tracker = new WebVitalsTracker();
tracker.generateReport();
}
module.exports = WebVitalsTracker;
+21
View File
@@ -0,0 +1,21 @@
import ErrorBoundary from "../app/components/ErrorBoundary";
export default {
title: "Components/ErrorBoundary",
component: ErrorBoundary,
parameters: {
layout: "centered",
docs: {
description: {
component:
"An error boundary component that catches JavaScript errors in its child component tree. Displays a fallback UI when errors occur and logs error information for debugging.",
},
},
},
argTypes: {
children: {
control: { type: "text" },
description: "Child components to wrap with error boundary",
},
},
};
+39
View File
@@ -0,0 +1,39 @@
import WebVitalsDashboard from "../app/components/WebVitalsDashboard";
export default {
title: "Components/WebVitalsDashboard",
component: WebVitalsDashboard,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A comprehensive dashboard component that displays real-time and historical Web Vitals data. Shows Core Web Vitals metrics, performance ratings, and optimization recommendations.",
},
},
},
argTypes: {},
};
export const Default = {
args: {},
parameters: {
docs: {
description: {
story:
"The default Web Vitals dashboard showing real-time performance metrics and historical data.",
},
},
},
};
export const Loading = {
args: {},
parameters: {
docs: {
description: {
story: "The dashboard in loading state while fetching Web Vitals data.",
},
},
},
};
+1 -1
View File
@@ -84,7 +84,7 @@ describe("LogoWall Component", () => {
const foodNotBombsLogo = screen.getByAltText("Food Not Bombs");
expect(foodNotBombsLogo).toHaveAttribute(
"src",
"assets/Section/Logo_FoodNotBombs.png",
"/assets/Section/Logo_FoodNotBombs.png",
);
expect(foodNotBombsLogo).toHaveClass("h-11", "lg:h-14", "xl:h-[70px]");
});