chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
.audioContainer {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--ifm-border-radius);
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: var(--ifm-box-shadow);
|
||||
}
|
||||
|
||||
.audioPlayer {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.transcriptLabel {
|
||||
font-weight: var(--ifm-font-weight-bold);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .audioContainer {
|
||||
background: var(--ifm-background-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .transcript {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import styles from './AudioPlayer.module.css';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioData: string; // base64 encoded audio data
|
||||
format?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({
|
||||
audioData,
|
||||
format = 'wav',
|
||||
transcript,
|
||||
}: AudioPlayerProps): React.ReactElement {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
const blob = new Blob([Buffer.from(audioData, 'base64')], { type: `audio/${format}` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
audioRef.current.src = url;
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
}, [audioData, format]);
|
||||
|
||||
return (
|
||||
<div className={styles.audioContainer}>
|
||||
<audio ref={audioRef} controls className={styles.audioPlayer} />
|
||||
{transcript && (
|
||||
<div className={styles.transcript}>
|
||||
<span className={styles.transcriptLabel}>Transcript:</span>
|
||||
{transcript}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
.blogPostCard {
|
||||
background-color: var(--ifm-card-background-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.blogPostCard:hover {
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-4px);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-color: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.blogPostImage {
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background-color: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.blogPostImage img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.blogPostCard:hover .blogPostImage img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.blogPostContent {
|
||||
padding: 1.25rem;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background-color: var(--ifm-color-primary-lightest);
|
||||
color: var(--ifm-color-primary-dark);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--ifm-heading-color);
|
||||
line-height: 1.4;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.blogPostCard:hover .title {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ifm-color-secondary-darkest);
|
||||
margin: 0.5rem 0 0 0;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.blogPostMeta {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ifm-color-secondary-darkest);
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.author::after {
|
||||
content: '·';
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (max-width: 900px) {
|
||||
.blogPostContent {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 0.8rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.blogPostMeta {
|
||||
padding-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablet - compact 2-column cards (600-700px) */
|
||||
@media (max-width: 700px) {
|
||||
.blogPostCard {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.blogPostContent {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blogPostMeta {
|
||||
padding-top: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - single column */
|
||||
@media (max-width: 600px) {
|
||||
.blogPostCard {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.blogPostCard:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.blogPostContent {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: -webkit-box;
|
||||
font-size: 0.875rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.blogPostMeta {
|
||||
padding-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 400px) {
|
||||
.blogPostContent {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.blogPostMeta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './BlogPostCard.module.css';
|
||||
import type { PropBlogPostContent } from '@docusaurus/plugin-content-blog';
|
||||
|
||||
// Format tag label: "red-teaming" → "Red Teaming", "ai-security" → "AI Security"
|
||||
function formatTagLabel(label: string): string {
|
||||
const acronyms = ['ai', 'llm', 'owasp', 'mcp', 'rag', 'agi', 'a2a', 'eu'];
|
||||
return label
|
||||
.split('-')
|
||||
.map((word) => {
|
||||
if (acronyms.includes(word.toLowerCase())) {
|
||||
return word.toUpperCase();
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: PropBlogPostContent;
|
||||
}
|
||||
|
||||
export default function BlogPostCard({ post }: BlogPostCardProps): React.ReactElement {
|
||||
const { metadata } = post;
|
||||
const { title, date, permalink, tags, description } = metadata;
|
||||
const { authors } = metadata;
|
||||
|
||||
// Format date as "Dec 12, 2025" style
|
||||
const formattedDate = new Date(date).toLocaleDateString('en-US', {
|
||||
timeZone: 'UTC',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
// Get the first tag if available
|
||||
const primaryTag = tags && tags.length > 0 ? tags[0] : null;
|
||||
|
||||
// Get first sentence of description for preview
|
||||
const preview = description ? description.split('. ')[0] + '.' : null;
|
||||
|
||||
const authorNames = authors.map((a) => a.name).join(' and ');
|
||||
|
||||
return (
|
||||
<Link to={permalink} className={styles.blogPostCard}>
|
||||
{metadata.frontMatter.image && (
|
||||
<div className={styles.blogPostImage}>
|
||||
<img src={metadata.frontMatter.image} alt={title} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.blogPostContent}>
|
||||
{primaryTag && <span className={styles.tag}>{formatTagLabel(primaryTag.label)}</span>}
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{preview && <p className={styles.preview}>{preview}</p>}
|
||||
<div className={styles.blogPostMeta}>
|
||||
{authorNames && <span className={styles.author}>{authorNames}</span>}
|
||||
<span className={styles.date}>{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
.blogPostGridContainer {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--ifm-heading-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.blogPostGridTitle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
background-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* Style for paginated pages (page 2+) */
|
||||
.blogPostGridTitle[data-is-paginated='true'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle[data-is-paginated='true']::after {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
/* Create styling for the page number in the title */
|
||||
.pageNumber {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background-color: var(--ifm-color-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.blogPostGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
|
||||
/* Tablet landscape */
|
||||
@media (max-width: 1100px) {
|
||||
.blogPostGridContainer {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.blogPostGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle::after {
|
||||
width: 50px;
|
||||
height: 2px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.pageNumber {
|
||||
font-size: 1.1rem;
|
||||
min-width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet portrait */
|
||||
@media (max-width: 900px) {
|
||||
.blogPostGridContainer {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.blogPostGrid {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pageNumber {
|
||||
font-size: 1rem;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.4rem;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablet / large phone - keep 2 columns */
|
||||
@media (max-width: 700px) {
|
||||
.blogPostGridContainer {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.blogPostGrid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle::after {
|
||||
width: 40px;
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
.pageNumber {
|
||||
font-size: 0.9rem;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.35rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - single column for phones and small tablets */
|
||||
@media (max-width: 600px) {
|
||||
.blogPostGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 400px) {
|
||||
.blogPostGridContainer {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.blogPostGridTitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.blogPostGrid {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import BlogPostGrid from './BlogPostGrid';
|
||||
|
||||
vi.mock('./BlogPostCard', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe('BlogPostGrid', () => {
|
||||
it('renders the latest posts heading on the first page', () => {
|
||||
render(<BlogPostGrid posts={[]} />);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Latest Posts' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('separates the older posts heading from the page number', () => {
|
||||
render(<BlogPostGrid posts={[]} title="Older Posts • Page 2" />);
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Older Posts 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import BlogPostCard from './BlogPostCard';
|
||||
import styles from './BlogPostGrid.module.css';
|
||||
import type { PropBlogPostContent } from '@docusaurus/plugin-content-blog';
|
||||
|
||||
interface BlogPostGridProps {
|
||||
posts: PropBlogPostContent[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function BlogPostGrid({
|
||||
posts,
|
||||
title = 'Latest Posts',
|
||||
}: BlogPostGridProps): React.ReactElement {
|
||||
// Check if this is a paginated page (not the first page)
|
||||
const isPaginatedPage = title.includes('Older Posts');
|
||||
|
||||
// If it's a paginated page, split the title to style the page number separately
|
||||
let mainTitle = title;
|
||||
let pageNumber = null;
|
||||
|
||||
if (isPaginatedPage) {
|
||||
const titleParts = title.split('•');
|
||||
mainTitle = titleParts[0].trim();
|
||||
|
||||
// Extract page number from the second part
|
||||
if (titleParts.length > 1) {
|
||||
const pageText = titleParts[1].trim();
|
||||
const pageNum = pageText.replace('Page ', '');
|
||||
|
||||
pageNumber = <span className={styles.pageNumber}>{pageNum}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.blogPostGridContainer}>
|
||||
<h2 className={styles.blogPostGridTitle} data-is-paginated={isPaginatedPage}>
|
||||
{pageNumber ? `${mainTitle} ` : mainTitle}
|
||||
{pageNumber}
|
||||
</h2>
|
||||
<div className={styles.blogPostGrid}>
|
||||
{posts.map((post, idx) => (
|
||||
<BlogPostCard key={idx} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function EnterpriseBanner(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className={styles.text}>
|
||||
This feature requires{' '}
|
||||
<Link to="/docs/enterprise/" className={styles.link}>
|
||||
Promptfoo Enterprise
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(229, 58, 58, 0.15);
|
||||
border-left: 3px solid var(--pf-red-base);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(229, 58, 58, 0.03) 0%, rgba(229, 58, 58, 0.06) 100%);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .banner {
|
||||
border-color: rgba(255, 122, 122, 0.15);
|
||||
border-left-color: var(--pf-red-light);
|
||||
background: linear-gradient(135deg, rgba(255, 122, 122, 0.04) 0%, rgba(255, 122, 122, 0.08) 100%);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--pf-red-base);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .iconWrapper {
|
||||
color: var(--pf-red-light);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--pf-red-darker);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .text {
|
||||
color: var(--pf-red-lighter);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--pf-red-darker);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(179, 46, 46, 0.3);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration-color: rgba(179, 46, 46, 0.8);
|
||||
color: var(--pf-red-darker);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .link {
|
||||
color: var(--pf-red-lighter);
|
||||
text-decoration-color: rgba(255, 160, 160, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .link:hover {
|
||||
text-decoration-color: rgba(255, 160, 160, 0.8);
|
||||
color: var(--pf-red-lighter);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.banner {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--ifm-card-background-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.04),
|
||||
0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.12),
|
||||
0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, var(--ifm-color-primary) 0%, rgba(37, 99, 235, 0.3) 100%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card {
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card:hover {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.4),
|
||||
0 8px 24px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
/* Image Container */
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ifm-color-emphasis-100) 0%,
|
||||
var(--ifm-color-emphasis-200) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.imageContainer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 0%, transparent 50%, rgba(0, 0, 0, 0.03) 100%);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .imageContainer::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card:hover .image {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.placeholderImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--ifm-color-primary) 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholderImage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-10%, -10%) rotate(0deg);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: translate(10%, 10%) rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 2.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
/* Image Overlay (for events like BSides SF 2026) */
|
||||
.imageOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 15, 26, 0.6) 0%,
|
||||
rgba(10, 15, 26, 0.2) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.overlayTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #f97316;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.overlayMeta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.upcoming {
|
||||
background: rgba(37, 99, 235, 0.9);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.past {
|
||||
background: rgba(30, 30, 30, 0.75);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
background: var(--ifm-card-background-color);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 650;
|
||||
margin: 0 0 0.875rem 0;
|
||||
line-height: 1.35;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
flex-shrink: 0;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1.25rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-primary);
|
||||
margin-top: auto;
|
||||
transition: gap 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover .cta {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ctaIcon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.card:hover .ctaIcon {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* Past event muted state */
|
||||
.card[data-event-status='past'] {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card[data-event-status='past']:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card[data-event-status='past'] .imageContainer::after {
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.05) 100%);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme='dark'] .content {
|
||||
background: var(--ifm-card-background-color);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badge.past {
|
||||
background: rgba(60, 60, 60, 0.85);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .description {
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .imageContainer {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ifm-color-emphasis-200) 0%,
|
||||
var(--ifm-color-emphasis-100) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.overlayTitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.overlayMeta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover state enhancement for touch devices */
|
||||
@media (hover: none) {
|
||||
.card:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.04),
|
||||
0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import { formatEventDate, formatEventLocation } from '../../data/events';
|
||||
import styles from './EventCard.module.css';
|
||||
|
||||
import type { Event } from '../../data/events';
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export default function EventCard({ event }: EventCardProps): React.ReactElement {
|
||||
// All events are linkable - either to their custom page or the events listing with hash
|
||||
const eventUrl = event.customPageUrl || `/events#${event.id}`;
|
||||
|
||||
const CardContent = () => (
|
||||
<>
|
||||
{/* Image */}
|
||||
<div className={styles.imageContainer}>
|
||||
{event.cardImage ? (
|
||||
<img src={event.cardImage} alt={event.name} className={styles.image} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.placeholderImage}>
|
||||
<span className={styles.placeholderIcon}>
|
||||
{event.type === 'conference' ? '🎪' : event.type === 'party' ? '🎉' : '📅'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`${styles.badge} ${styles[event.status]}`}>
|
||||
{event.status === 'upcoming' ? 'Upcoming' : 'Past'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.content}>
|
||||
<h3 className={styles.name}>{event.shortName}</h3>
|
||||
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.metaItem}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{formatEventDate(event.startDate, event.endDate)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaItem}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{formatEventLocation(event.location)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.description}>{event.description}</p>
|
||||
|
||||
<span className={styles.cta}>
|
||||
View event
|
||||
<svg
|
||||
className={styles.ctaIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link to={eventUrl} className={styles.card}>
|
||||
<CardContent />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.8) 0%, rgba(248, 250, 252, 0.9) 100%);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 2.5rem;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.02),
|
||||
0 4px 12px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.filters::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 14px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.1) 0%,
|
||||
rgba(148, 163, 184, 0.1) 50%,
|
||||
rgba(37, 99, 235, 0.05) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filters {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.6) 0%, rgba(15, 23, 42, 0.8) 100%);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filters::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.2) 0%,
|
||||
rgba(99, 102, 241, 0.1) 50%,
|
||||
rgba(37, 99, 235, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterButtons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 550;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.15s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filterButton:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(37, 99, 235, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.filterButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.filterButton.active {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.filterButton.active:hover {
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(37, 99, 235, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
padding: 0 0.4rem;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 100px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.filterButton.active .count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 550;
|
||||
color: var(--ifm-color-primary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clearButton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0.375rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transform: scaleX(0);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.clearButton:hover::after {
|
||||
opacity: 0.5;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme='dark'] .filterButton {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-color: rgba(148, 163, 184, 0.15);
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filterButton:hover {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .count {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.filterButtons {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 0.375rem;
|
||||
margin-bottom: -0.375rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filterButtons::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Disable hover transforms on mobile */
|
||||
.filterButton:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.filterButton:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
align-self: flex-end;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimization */
|
||||
@media (hover: none) {
|
||||
.filterButton:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.filterButton.active:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.filterButton:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filterButton:hover {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border-color: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './EventFilters.module.css';
|
||||
|
||||
import type { EventStatus } from '../../data/events';
|
||||
|
||||
export type FilterStatus = EventStatus | 'all';
|
||||
export type FilterYear = number | 'all';
|
||||
|
||||
interface EventFiltersProps {
|
||||
selectedStatus: FilterStatus;
|
||||
selectedYear: FilterYear;
|
||||
availableYears: number[];
|
||||
onStatusChange: (status: FilterStatus) => void;
|
||||
onYearChange: (year: FilterYear) => void;
|
||||
eventCounts: {
|
||||
all: number;
|
||||
upcoming: number;
|
||||
past: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EventFilters({
|
||||
selectedStatus,
|
||||
selectedYear,
|
||||
availableYears,
|
||||
onStatusChange,
|
||||
onYearChange,
|
||||
eventCounts,
|
||||
}: EventFiltersProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
{/* Status Filter */}
|
||||
<div className={styles.filterGroup}>
|
||||
<span className={styles.filterLabel}>Status</span>
|
||||
<div className={styles.filterButtons}>
|
||||
<button
|
||||
className={`${styles.filterButton} ${selectedStatus === 'all' ? styles.active : ''}`}
|
||||
onClick={() => onStatusChange('all')}
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
<span className={styles.count}>{eventCounts.all}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.filterButton} ${selectedStatus === 'upcoming' ? styles.active : ''}`}
|
||||
onClick={() => onStatusChange('upcoming')}
|
||||
type="button"
|
||||
>
|
||||
Upcoming
|
||||
<span className={styles.count}>{eventCounts.upcoming}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.filterButton} ${selectedStatus === 'past' ? styles.active : ''}`}
|
||||
onClick={() => onStatusChange('past')}
|
||||
type="button"
|
||||
>
|
||||
Past
|
||||
<span className={styles.count}>{eventCounts.past}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className={styles.filterGroup}>
|
||||
<span className={styles.filterLabel}>Year</span>
|
||||
<div className={styles.filterButtons}>
|
||||
<button
|
||||
className={`${styles.filterButton} ${selectedYear === 'all' ? styles.active : ''}`}
|
||||
onClick={() => onYearChange('all')}
|
||||
type="button"
|
||||
>
|
||||
All Years
|
||||
</button>
|
||||
{availableYears.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`${styles.filterButton} ${selectedYear === year ? styles.active : ''}`}
|
||||
onClick={() => onYearChange(year)}
|
||||
type="button"
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(selectedStatus !== 'all' || selectedYear !== 'all') && (
|
||||
<button
|
||||
className={styles.clearButton}
|
||||
onClick={() => {
|
||||
onStatusChange('all');
|
||||
onYearChange('all');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
.featured {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 3rem;
|
||||
margin-bottom: 3.5rem;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 50%, #f5f9ff 100%);
|
||||
}
|
||||
|
||||
.featured::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(37, 99, 235, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 60%, rgba(99, 102, 241, 0.08) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.featured::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
border-radius: 20px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.3) 0%,
|
||||
rgba(99, 102, 241, 0.15) 50%,
|
||||
rgba(37, 99, 235, 0.2) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featured {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.15) 0%,
|
||||
rgba(30, 41, 59, 0.8) 50%,
|
||||
rgba(15, 23, 42, 0.9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featured::before {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(37, 99, 235, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 60%, rgba(99, 102, 241, 0.15) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featured::after {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.5) 0%,
|
||||
rgba(99, 102, 241, 0.2) 50%,
|
||||
rgba(37, 99, 235, 0.3) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 3.5rem;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.badgeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #34d399;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px rgba(52, 211, 153, 0.6);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 750;
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
color: var(--ifm-color-primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.0625rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
line-height: 1.65;
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .description {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.875rem 1.75rem;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(37, 99, 235, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(37, 99, 235, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.primaryButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryButton:hover .buttonIcon {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.imageWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.imageWrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
background: radial-gradient(circle at center, rgba(37, 99, 235, 0.1) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Image Overlay (for BSides SF 2026) */
|
||||
.imageOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 15, 26, 0.7) 0%,
|
||||
rgba(10, 15, 26, 0.3) 40%,
|
||||
transparent 60%
|
||||
);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.overlayTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #f97316;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.overlayMeta {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.overlayVenue {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .imageContainer {
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .image {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.placeholderImage {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(37, 99, 235, 0.25),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.placeholderImage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.15) 0%, transparent 40%);
|
||||
animation: rotateGlow 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotateGlow {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 996px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.imageWrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.imageContainer,
|
||||
.placeholderImage {
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.featured {
|
||||
padding: 2rem 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.featured::after {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.625rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.imageContainer,
|
||||
.placeholderImage {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.overlayTitle {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.overlayMeta {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.overlayVenue {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.featured {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import { formatEventDate, formatEventLocation } from '../../data/events';
|
||||
import styles from './FeaturedEvent.module.css';
|
||||
|
||||
import type { Event } from '../../data/events';
|
||||
|
||||
interface FeaturedEventProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export default function FeaturedEvent({ event }: FeaturedEventProps): React.ReactElement {
|
||||
const hasExistingPage = Boolean(event.customPageUrl);
|
||||
const eventUrl = event.customPageUrl || `/events/${event.slug}`;
|
||||
|
||||
return (
|
||||
<section className={styles.featured}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.badge}>Upcoming Event</span>
|
||||
|
||||
<h2 className={styles.title}>{event.name}</h2>
|
||||
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.metaItem}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{formatEventDate(event.startDate, event.endDate)}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaItem}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{formatEventLocation(event.location)}</span>
|
||||
</div>
|
||||
|
||||
{event.booth && (
|
||||
<div className={styles.metaItem}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>{event.booth}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.description}>{event.fullDescription || event.description}</p>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
{hasExistingPage ? (
|
||||
<Link to={eventUrl} className={styles.primaryButton}>
|
||||
View event details
|
||||
<svg
|
||||
className={styles.buttonIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
) : event.registrationUrl ? (
|
||||
<a
|
||||
href={event.registrationUrl}
|
||||
className={styles.primaryButton}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Register now
|
||||
<svg
|
||||
className={styles.buttonIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
) : event.meetingUrl ? (
|
||||
<a
|
||||
href={event.meetingUrl}
|
||||
className={styles.primaryButton}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Book a meeting
|
||||
<svg
|
||||
className={styles.buttonIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<Link to="/contact" className={styles.primaryButton}>
|
||||
Get in touch
|
||||
<svg
|
||||
className={styles.buttonIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.imageWrapper}>
|
||||
<div className={styles.imageContainer}>
|
||||
{event.heroImage ? (
|
||||
<img src={event.heroImage} alt={event.name} className={styles.image} />
|
||||
) : (
|
||||
<div className={styles.placeholderImage}>
|
||||
<span className={styles.placeholderIcon}>
|
||||
{event.type === 'conference' ? '🎪' : event.type === 'party' ? '🎉' : '📅'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as EventCard } from './EventCard';
|
||||
export { default as EventFilters } from './EventFilters';
|
||||
export { default as FeaturedEvent } from './FeaturedEvent';
|
||||
|
||||
export type { FilterStatus, FilterYear } from './EventFilters';
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type EventBannerItem = {
|
||||
badge: string;
|
||||
cta: string;
|
||||
date: string;
|
||||
href: string;
|
||||
location: string;
|
||||
};
|
||||
|
||||
const ACTIVE_EVENTS: EventBannerItem[] = [];
|
||||
|
||||
function getActiveEvents(): EventBannerItem[] {
|
||||
return ACTIVE_EVENTS;
|
||||
}
|
||||
|
||||
export default function EventsBanner(): React.ReactElement | null {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const location = useLocation();
|
||||
const activeEvents = getActiveEvents();
|
||||
const shouldShowBanner =
|
||||
activeEvents.length > 0 &&
|
||||
!activeEvents.some((event) => location.pathname.includes(event.href));
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissed = sessionStorage.getItem('eventsBannerDismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [shouldShowBanner]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
sessionStorage.setItem('eventsBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
if (!shouldShowBanner || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.backgroundAnimation}>
|
||||
<div className={styles.particle1} />
|
||||
<div className={styles.particle2} />
|
||||
<div className={styles.particle3} />
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.leftSection}>
|
||||
<span className={styles.megaphone} aria-hidden="true">
|
||||
!
|
||||
</span>
|
||||
<span className={styles.heading}>JOIN US AT</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.eventsContainer}>
|
||||
{activeEvents.map((event, index) => (
|
||||
<React.Fragment key={event.href}>
|
||||
{index > 0 && (
|
||||
<div className={styles.divider}>
|
||||
<span className={styles.plus}>+</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to={event.href} className={styles.eventCard}>
|
||||
<div className={styles.eventBadge}>{event.badge}</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<span className={styles.eventDate}>{event.date}</span>
|
||||
<span className={styles.eventLocation}>{event.location}</span>
|
||||
</div>
|
||||
<span className={styles.eventCta}>{event.cta}</span>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className={styles.closeButton} onClick={handleDismiss} aria-label="Dismiss banner">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(179, 46, 46, 0.2) 0%, transparent 45%),
|
||||
radial-gradient(ellipse at bottom right, rgba(203, 52, 52, 0.15) 0%, transparent 45%),
|
||||
linear-gradient(135deg, #10191c 0%, #17252b 35%, #2e3c46 70%, #17252b 100%);
|
||||
overflow: hidden;
|
||||
animation: bannerGlow 4s ease-in-out infinite alternate;
|
||||
border-bottom: 2px solid rgba(179, 46, 46, 0.3);
|
||||
}
|
||||
|
||||
.banner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(179, 46, 46, 0.08), transparent);
|
||||
animation: sweep 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bannerGlow {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 1px 15px rgba(179, 46, 46, 0.25),
|
||||
inset 0 1px 0 rgba(229, 58, 58, 0.1);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 1px 25px rgba(179, 46, 46, 0.35),
|
||||
inset 0 1px 0 rgba(229, 58, 58, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.backgroundAnimation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.particle1,
|
||||
.particle2,
|
||||
.particle3 {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: linear-gradient(135deg, #e53a3a, #cb3434);
|
||||
border-radius: 50%;
|
||||
opacity: 0.3;
|
||||
box-shadow: 0 0 4px rgba(229, 58, 58, 0.4);
|
||||
}
|
||||
|
||||
.particle1 {
|
||||
animation: float1 15s infinite linear;
|
||||
}
|
||||
|
||||
.particle2 {
|
||||
animation: float2 20s infinite linear;
|
||||
}
|
||||
|
||||
.particle3 {
|
||||
animation: float3 25s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes float1 {
|
||||
0% {
|
||||
transform: translateX(-100px) translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(100vw + 100px)) translateY(-30px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
0% {
|
||||
transform: translateX(-100px) translateY(20px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(100vw + 100px)) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
0% {
|
||||
transform: translateX(-100px) translateY(40px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(100vw + 100px)) translateY(50px);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 20px;
|
||||
gap: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.megaphone {
|
||||
font-size: 18px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
filter: drop-shadow(0 0 4px rgba(229, 58, 58, 0.5));
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 4px rgba(229, 58, 58, 0.5));
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
filter: drop-shadow(0 0 8px rgba(229, 58, 58, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 2px solid rgba(229, 58, 58, 0.2);
|
||||
border-radius: 10px;
|
||||
text-decoration: none !important;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eventCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, #991515, #b32e2e, #cb3434, #e53a3a);
|
||||
border-radius: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: -1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.eventCard:hover {
|
||||
transform: translateY(-3px);
|
||||
background: rgba(229, 58, 58, 0.1);
|
||||
border-color: rgba(229, 58, 58, 0.4);
|
||||
box-shadow: 0 8px 20px rgba(229, 58, 58, 0.3);
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.eventCard:hover::before {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.eventBadge {
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.eventDate {
|
||||
color: #ff7a7a;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 4px rgba(229, 58, 58, 0.5);
|
||||
}
|
||||
|
||||
.eventLocation {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.eventCta {
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eventCard:hover .eventCta {
|
||||
opacity: 1;
|
||||
color: #ff5555;
|
||||
text-shadow: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plus {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
animation: rotate 10s linear infinite;
|
||||
text-shadow: 0 0 4px rgba(229, 58, 58, 0.3);
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #991515, #b32e2e, #cb3434, #e53a3a, #cb3434, #b32e2e, #991515);
|
||||
background-size: 200% 100%;
|
||||
animation:
|
||||
progress 30s linear infinite,
|
||||
shimmer 3s ease-in-out infinite;
|
||||
box-shadow: 0 0 6px rgba(179, 46, 46, 0.5);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 968px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
padding: 8px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leftSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.eventsContainer {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.megaphone {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Forces light theme via DOM attribute without touching Docusaurus state or
|
||||
* localStorage, so the user's stored preference (dark/system) survives
|
||||
* navigation through non-docs pages.
|
||||
*/
|
||||
export default function ForceLightTheme(): null {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
const forceLight = () => {
|
||||
if (root.getAttribute('data-theme') !== 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
forceLight();
|
||||
|
||||
// Re-enforce if Docusaurus reacts to system preference or stored value
|
||||
const observer = new MutationObserver(forceLight);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
const CACHE_KEY = 'github_stars_cache_promptfoo';
|
||||
const CACHE_DURATION = 1000 * 60 * 60 * 24; // 1 day
|
||||
const FETCH_TIMEOUT = 5000; // 5 seconds
|
||||
|
||||
interface CachedData {
|
||||
stars: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function formatStarCount(count: number): string {
|
||||
if (count < 1000) {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
const formatted = (count / 1000).toFixed(1);
|
||||
const trimmed = formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted;
|
||||
return `${trimmed}k`;
|
||||
}
|
||||
|
||||
function getCachedStars(): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (cached) {
|
||||
const data: CachedData = JSON.parse(cached);
|
||||
if (Date.now() - data.timestamp < CACHE_DURATION) {
|
||||
return data.stars;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedStars(stars: string): void {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ stars, timestamp: Date.now() }));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
export default function GitHubStars(): React.ReactElement {
|
||||
const [stars, setStars] = useState<string>(
|
||||
() => getCachedStars() || SITE_CONSTANTS.GITHUB_STARS_DISPLAY,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip fetch if we have cached data
|
||||
if (getCachedStars()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||
|
||||
fetch('https://api.github.com/repos/promptfoo/promptfoo', {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (typeof data.stargazers_count === 'number') {
|
||||
const formatted = formatStarCount(data.stargazers_count);
|
||||
setStars(formatted);
|
||||
setCachedStars(formatted);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error fetching GitHub stars:', error);
|
||||
}
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/promptfoo/promptfoo#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.githubStars}
|
||||
aria-label={`${stars} stars on GitHub`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={styles.githubIcon}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
<span className={styles.starCount}>{stars}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.githubStars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.githubStars:hover {
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.githubIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.starCount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.githubStars {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
:global(.docs-wrapper) .githubStars {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function HomepageFeatures() {
|
||||
const features = [
|
||||
{
|
||||
title: 'Find vulnerabilities you actually care about',
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
Our <strong>specialized language models</strong> generate attacks specific to your
|
||||
industry, company, and application.
|
||||
</p>
|
||||
<p>No generic canned attacks - every attack is created on-the-fly.</p>
|
||||
</>
|
||||
),
|
||||
image: '/img/security-coverage.png',
|
||||
image2x: '/img/security-coverage.png',
|
||||
alt: 'promptfoo security coverage examples',
|
||||
link: '/docs/red-team/llm-vulnerability-types/',
|
||||
cta: 'Learn More',
|
||||
},
|
||||
{
|
||||
title: 'Battle-tested at enterprise scale',
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
Adopted by <strong>{SITE_CONSTANTS.FORTUNE_500_COUNT} Fortune 500 companies</strong>{' '}
|
||||
shipping apps to hundreds of millions of users.
|
||||
</p>
|
||||
<p>
|
||||
Embraced by an open-source community of{' '}
|
||||
<strong>over {SITE_CONSTANTS.USER_COUNT_DISPLAY} developers</strong> worldwide.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
image: '/img/f500-usage.svg',
|
||||
image2x: '/img/f500-usage.svg',
|
||||
alt: 'promptfoo quickstart',
|
||||
link: '/docs/red-team/quickstart/',
|
||||
cta: 'Get Started',
|
||||
className: 'f500',
|
||||
},
|
||||
/*
|
||||
{
|
||||
title: 'Set your standards',
|
||||
description: (
|
||||
<>
|
||||
<p>Map to OWASP, NIST, MITRE, EU AI Act, and more.</p>
|
||||
<p>
|
||||
Customizable to your specific policies and industry regulations, with detailed
|
||||
reporting.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
image: '/img/compliance-frameworks.png',
|
||||
image2x: '/img/compliance-frameworks.png',
|
||||
alt: 'promptfoo compliance reporting',
|
||||
link: '/red-teaming/',
|
||||
cta: 'Learn More',
|
||||
},
|
||||
*/
|
||||
{
|
||||
title: 'Security-first, developer-friendly',
|
||||
image:
|
||||
'https://user-images.githubusercontent.com/310310/244891726-480e1114-d049-40b9-bd5f-f81c15060284.gif',
|
||||
image2x:
|
||||
'https://user-images.githubusercontent.com/310310/244891726-480e1114-d049-40b9-bd5f-f81c15060284.gif',
|
||||
description:
|
||||
'Move quickly with a command-line interface, live reloads, and caching. No SDKs, cloud dependencies, or logins.',
|
||||
link: '/docs/red-team/quickstart/',
|
||||
cta: 'Get Started',
|
||||
},
|
||||
{
|
||||
title: 'Deploy your way',
|
||||
description:
|
||||
'Get started in minutes with our CLI tool, or choose our managed cloud or on-premises enterprise solutions for advanced features and support.',
|
||||
image: '/img/deploy-options.svg',
|
||||
image2x: '/img/deploy-options.svg',
|
||||
alt: 'promptfoo deployment options',
|
||||
link: '/contact/',
|
||||
cta: 'Get Started',
|
||||
className: 'noBorder',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<h2 className={styles.featuresTitle}>Why Promptfoo?</h2>
|
||||
<div className={styles.featuresList}>
|
||||
{features.map((feature, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.featureItem} ${feature.className ? styles[feature.className] : ''}`}
|
||||
>
|
||||
<div className={styles.featureContent}>
|
||||
<h3>{feature.title}</h3>
|
||||
<div>{feature.description}</div>
|
||||
<Link to={feature.link} className="button button--secondary">
|
||||
{feature.cta}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.featureImageWrapper}>
|
||||
<Link to={feature.link}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={feature.image}
|
||||
srcSet={`${feature.image} 1x, ${feature.image2x} 2x`}
|
||||
alt={feature.alt}
|
||||
className={`${styles.featureImage} ${feature.className ? styles[feature.className + 'Image'] : ''}`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
.features {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.featuresTitle {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.featuresList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.featureItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.featureItem:nth-child(even) {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.featureContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.featureContent h3 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.featureContent p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.featureImageWrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.featureImage {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.featureImage:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.f500 img {
|
||||
width: min(500px, 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* No border or shadow styles */
|
||||
.noBorder {
|
||||
/* Custom styles for the container if needed */
|
||||
}
|
||||
|
||||
.noBorderImage {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.noBorderImage:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.featureItem,
|
||||
.featureItem:nth-child(even) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.featureContent,
|
||||
.featureImageWrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function HomepageInfo(): React.ReactElement {
|
||||
type TabId = 'evaluations' | 'security';
|
||||
const [activeTab, setActiveTab] = useState<TabId>('security');
|
||||
|
||||
const tabs: Array<{ id: TabId; label: string; mobileLabel: string }> = [
|
||||
{ id: 'evaluations', label: 'Quality evaluations', mobileLabel: 'Evaluations' },
|
||||
{ id: 'security', label: 'Pre-deployment security scans', mobileLabel: 'Security' },
|
||||
];
|
||||
|
||||
const config: Array<{ id: TabId; code: string; image: string }> = [
|
||||
{
|
||||
id: 'evaluations',
|
||||
code: `
|
||||
# Compare prompts...
|
||||
prompts:
|
||||
- "Summarize this in {{language}}: {{document}}"
|
||||
- "Summarize this in {{language}}, concisely and professionally: {{document}}"
|
||||
|
||||
# And models...
|
||||
providers:
|
||||
- openai:gpt-5
|
||||
- anthropic:claude-3.5-sonnet
|
||||
|
||||
# ... using these tests
|
||||
tests:
|
||||
- vars:
|
||||
language: French
|
||||
document: "file://docs/*.txt"
|
||||
assert:
|
||||
- type: contains
|
||||
value: "foo bar"
|
||||
- type: llm-rubric
|
||||
value: "does not apologize"
|
||||
- type: cost
|
||||
threshold: 0.01
|
||||
- type: latency
|
||||
threshold: 3000
|
||||
- # ...
|
||||
`,
|
||||
image:
|
||||
'https://user-images.githubusercontent.com/310310/261666627-ce5a7817-da82-4484-b26d-32474f1cabc5.png',
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
code: `
|
||||
# Test cases are generated to specifically target the system's use case
|
||||
purpose: 'Budget travel agent'
|
||||
|
||||
# Define where payloads are sent
|
||||
targets:
|
||||
- id: 'https://example.com/generate'
|
||||
config:
|
||||
method: 'POST'
|
||||
headers:
|
||||
'Content-Type': 'application/json'
|
||||
body:
|
||||
userInput: '{{prompt}}'
|
||||
`,
|
||||
image: '/img/risk-category-drawer@2x.png',
|
||||
},
|
||||
];
|
||||
|
||||
const activeConfig = config.find((item) => item.id === activeTab) ?? config[0];
|
||||
|
||||
return (
|
||||
<div className={styles.infoContainer}>
|
||||
<section className={styles.section}>
|
||||
<h2>Easy abstractions for complex LLM testing</h2>
|
||||
<div className={styles.exampleTabs}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`${styles.exampleTab} ${activeTab === tab.id ? styles.exampleTabActive : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className={styles.tabLabelDesktop}>{tab.label}</span>
|
||||
<span className={styles.tabLabelMobile}>{tab.mobileLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.configToWebview}>
|
||||
<div className={styles.codeExample}>
|
||||
<div className={styles.featureTitle}>
|
||||
<SettingsIcon /> Simple declarative config
|
||||
</div>
|
||||
<CodeBlock language="yaml" className={styles.codeBlock}>
|
||||
{activeConfig.code}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div className={styles.webviewExample}>
|
||||
<div className={styles.featureTitle}>
|
||||
<AssessmentIcon /> Detailed, actionable results
|
||||
</div>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={activeConfig.image}
|
||||
className={styles.resultImage}
|
||||
alt={`${activeTab === 'evaluations' ? 'Evaluation' : 'Security'} Results`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
:root {
|
||||
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-duration: 0.2s;
|
||||
--border-radius-sm: 6px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
--border-radius-pill: 100px;
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
--blue-primary: #1890ff;
|
||||
--blue-hover: #40a9ff;
|
||||
}
|
||||
|
||||
.infoContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin: 4rem 0 6rem 0;
|
||||
}
|
||||
|
||||
.infoContainer h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blurb {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.codeExample,
|
||||
.webviewExample {
|
||||
flex: 1;
|
||||
max-width: 48%;
|
||||
background: white;
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-duration) var(--transition-timing);
|
||||
}
|
||||
|
||||
.codeExample {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.webviewExample {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.configToWebview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
margin-top: 3rem;
|
||||
gap: 2rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2c3e50;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.featureTitle svg {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.resultImage {
|
||||
max-width: 100%;
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-duration) var(--transition-timing);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.configToWebview {
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.codeExample,
|
||||
.webviewExample {
|
||||
max-width: 100%;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .codeExample,
|
||||
[data-theme='dark'] .webviewExample {
|
||||
backdrop-filter: none;
|
||||
background: rgba(22, 27, 34, 0.95);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .configToWebview {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.exampleTabs {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.exampleTab {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tabLabelDesktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabLabelMobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.exampleTab {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.exampleTabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 2.5rem 0 2rem;
|
||||
padding: 0.375rem;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: var(--border-radius-pill);
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.exampleTab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-duration) var(--transition-timing);
|
||||
border-radius: var(--border-radius-pill);
|
||||
color: #666;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tabLabelDesktop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tabLabelMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.exampleTabActive {
|
||||
background: white;
|
||||
color: var(--blue-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--shadow-sm: 0 2px 8px rgba(24, 144, 255, 0.2);
|
||||
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTabs {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTab {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTabActive {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
color: var(--blue-hover);
|
||||
box-shadow:
|
||||
var(--shadow-sm),
|
||||
inset 0 0 0 1px rgba(64, 169, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .codeExample,
|
||||
[data-theme='dark'] .webviewExample {
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(99, 99, 99, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .codeExample {
|
||||
background: rgba(13, 17, 23, 0.8);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .codeExample:hover,
|
||||
[data-theme='dark'] .webviewExample:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(99, 99, 99, 0.25);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureTitle {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .featureTitle svg {
|
||||
color: #40a9ff;
|
||||
filter: drop-shadow(0 2px 8px rgba(64, 169, 255, 0.3));
|
||||
}
|
||||
|
||||
[data-theme='dark'] .resultImage {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(99, 99, 99, 0.2);
|
||||
filter: brightness(0.95) contrast(1.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .resultImage:hover {
|
||||
filter: brightness(1) contrast(1.05);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .codeBlock {
|
||||
background: rgba(13, 17, 23, 0.6) !important;
|
||||
border: 1px solid rgba(99, 99, 99, 0.15);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTabs {
|
||||
border-bottom-color: rgba(99, 99, 99, 0.2);
|
||||
background: rgba(30, 30, 30, 0.3);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTab {
|
||||
color: #a0a0a0;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTabActive {
|
||||
background: rgba(64, 169, 255, 0.1);
|
||||
border-bottom: 2px solid #40a9ff;
|
||||
color: #40a9ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .exampleTab:hover:not(.exampleTabActive) {
|
||||
color: #40a9ff;
|
||||
background: rgba(64, 169, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
[data-theme='dark'] .codeExample,
|
||||
[data-theme='dark'] .webviewExample {
|
||||
backdrop-filter: none;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ... rest of the existing styles ... */
|
||||
|
||||
/* Responsive improvements */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.configToWebview {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.codeExample,
|
||||
.webviewExample {
|
||||
max-width: 49%;
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove backdrop-filter on lower-end devices */
|
||||
@media (max-width: 768px) and (prefers-reduced-transparency: reduce) {
|
||||
[data-theme='dark'] .exampleTabs,
|
||||
[data-theme='dark'] .codeExample,
|
||||
[data-theme='dark'] .webviewExample {
|
||||
backdrop-filter: none;
|
||||
background: rgba(22, 27, 34, 0.95);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-top: 4rem;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.container:not(.flipped):hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.imageWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transition: filter 0.5s linear;
|
||||
}
|
||||
|
||||
.container:not(.flipped) .image {
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flipped .title {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.caption {
|
||||
background-color: #000;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import styles from './ImageJailbreakPreview.module.css';
|
||||
|
||||
interface ImageJailbreakPreviewProps {
|
||||
title: string;
|
||||
images: { src: string; caption: string }[];
|
||||
}
|
||||
|
||||
const ImageJailbreakPreview: React.FC<ImageJailbreakPreviewProps> = ({ title, images }) => {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setFlipped(!flipped);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.container} ${flipped ? styles.flipped : ''}`} onClick={handleClick}>
|
||||
{!flipped && (
|
||||
<>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{images.length > 0 && (
|
||||
<div className={styles.imageWrapper}>
|
||||
<img
|
||||
src={images[0].src}
|
||||
alt={images[0].caption}
|
||||
className={`${styles.image} no-zoom`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{flipped &&
|
||||
images.map((image, index) => (
|
||||
<div key={index} className={styles.imageWrapper}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src={image.src}
|
||||
alt={image.caption}
|
||||
className={`${styles.image} no-zoom`}
|
||||
/>
|
||||
<p className={styles.caption}>
|
||||
<strong>Dall-E refused prompt:</strong> {title}
|
||||
<br />
|
||||
<br />
|
||||
<strong>Jailbreak prompt:</strong> {image.caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageJailbreakPreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.noBackground {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.noBorder {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.logoContainer img {
|
||||
max-width: 150px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.logoContainer img:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .logoContainer {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .logoContainer img {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .logoContainer img:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Apply invert filter to all logos in dark mode for better visibility */
|
||||
[data-theme='dark'] .logoContainer img {
|
||||
filter: brightness(0) invert(0.9);
|
||||
}
|
||||
|
||||
/* Mobile responsive - progressively hide logos from the end */
|
||||
@media (max-width: 1200px) {
|
||||
.logoContainer {
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
/* Show first 5 logos */
|
||||
.logoContainer img:nth-child(n + 6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.logoContainer {
|
||||
gap: 2.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.logoContainer img {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* Show first 4 logos */
|
||||
.logoContainer img:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.logoContainer {
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.logoContainer img {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
/* Show first 3 logos */
|
||||
.logoContainer img:nth-child(n + 4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logoContainer {
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.logoContainer img {
|
||||
max-width: 90px;
|
||||
}
|
||||
|
||||
/* Show first 2 logos */
|
||||
.logoContainer img:nth-child(n + 3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import styles from './LogoContainer.module.css';
|
||||
|
||||
interface LogoContainerProps {
|
||||
className?: string;
|
||||
noBackground?: boolean;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
export default function LogoContainer({
|
||||
className,
|
||||
noBackground = false,
|
||||
noBorder = false,
|
||||
}: LogoContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.logoContainer,
|
||||
noBackground && styles.noBackground,
|
||||
noBorder && styles.noBorder,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
style={{ maxWidth: '100px' }}
|
||||
className={styles.shopify}
|
||||
src="/img/brands/shopify-logo.svg"
|
||||
alt="Shopify"
|
||||
/>
|
||||
<img src="/img/brands/discord-logo-blue.svg" alt="Discord" />
|
||||
<img
|
||||
style={{ maxWidth: '110px' }}
|
||||
className={styles.anthropic}
|
||||
src="/img/brands/anthropic-logo.svg"
|
||||
alt="Anthropic"
|
||||
/>
|
||||
<img style={{ maxWidth: '100px' }} src="/img/brands/microsoft-logo.svg" alt="Microsoft" />
|
||||
<img style={{ maxHeight: '50px' }} src="/img/brands/doordash-logo.svg" alt="Doordash" />
|
||||
<img style={{ maxHeight: '120px' }} src="/img/brands/carvana-logo.svg" alt="Carvana" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
|
||||
import AppsIcon from '@mui/icons-material/Apps';
|
||||
import CloudDoneIcon from '@mui/icons-material/CloudDone';
|
||||
import CloudOffIcon from '@mui/icons-material/CloudOff';
|
||||
import GroupIcon from '@mui/icons-material/Group';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export default function MCPProxyDiagram() {
|
||||
const AV = 80; // avatar diameter
|
||||
const PROXY_D = 200; // proxy circle diameter
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 900,
|
||||
mx: 'auto',
|
||||
p: 4,
|
||||
bgcolor: 'var(--ifm-background-surface-color)',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: 4,
|
||||
boxShadow: 3,
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Left column: Users & Apps */}
|
||||
<Grid size={{ xs: 12, sm: 3 }}>
|
||||
<Stack
|
||||
spacing={8}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: PROXY_D + 100,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: '#1976d2', width: AV, height: AV }}>
|
||||
<GroupIcon sx={{ fontSize: 36, color: '#fff' }} />
|
||||
</Avatar>
|
||||
<Typography variant="h6">Users</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: '#7b1fa2', width: AV, height: AV }}>
|
||||
<AppsIcon sx={{ fontSize: 36, color: '#fff' }} />
|
||||
</Avatar>
|
||||
<Typography variant="h6">Apps</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Center column: Proxy */}
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<Stack
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: PROXY_D,
|
||||
height: PROXY_D,
|
||||
border: '6px solid #43a047',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--ifm-background-color)',
|
||||
}}
|
||||
>
|
||||
<SecurityIcon sx={{ fontSize: 80, color: '#43a047' }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
bgcolor: 'var(--ifm-background-color)',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '50%',
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: 24, color: '#fbc02d' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
MCP Proxy
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right column: Trusted & Unapproved */}
|
||||
<Grid size={{ xs: 12, sm: 3 }}>
|
||||
<Stack
|
||||
spacing={8}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: PROXY_D + 100,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: '#43a047', width: AV, height: AV }}>
|
||||
<CloudDoneIcon sx={{ fontSize: 36, color: '#fff' }} />
|
||||
</Avatar>
|
||||
<Typography variant="h6">Trusted MCP</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: '#f44336', width: AV, height: AV }}>
|
||||
<CloudOffIcon sx={{ fontSize: 36, color: '#fff' }} />
|
||||
</Avatar>
|
||||
<Typography variant="h6">Unapproved MCP</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
import Link from '@docusaurus/Link';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export interface NavMenuCardItem {
|
||||
to?: string;
|
||||
href?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
type?: 'section-header' | 'link';
|
||||
sectionTitle?: string;
|
||||
}
|
||||
|
||||
interface NavMenuCardProps {
|
||||
label: string;
|
||||
items: NavMenuCardItem[];
|
||||
position?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function NavMenuCard({
|
||||
label,
|
||||
items,
|
||||
position = 'left',
|
||||
}: NavMenuCardProps): React.ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownId = useId();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const canHover = () => window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (canHover()) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
// Group items by section
|
||||
const sections: { title?: string; items: NavMenuCardItem[] }[] = [];
|
||||
let currentSection: { title?: string; items: NavMenuCardItem[] } = { items: [] };
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.type === 'section-header') {
|
||||
if (currentSection.items.length > 0) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
currentSection = { title: item.label, items: [] };
|
||||
} else if (!item.type?.startsWith('html')) {
|
||||
currentSection.items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Push the last section if it has items
|
||||
if (currentSection.items.length > 0) {
|
||||
sections.push(currentSection);
|
||||
}
|
||||
|
||||
// If no sections were created, put all items in a single section
|
||||
const displaySections =
|
||||
sections.length > 0
|
||||
? sections
|
||||
: [
|
||||
{
|
||||
items: items.filter(
|
||||
(item) => !item.type?.startsWith('html') && item.type !== 'section-header',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.navMenuCard}
|
||||
ref={menuRef}
|
||||
onMouseEnter={() => {
|
||||
if (canHover()) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (canHover()) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
if (!menuRef.current?.contains(event.relatedTarget as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navMenuCardButton} ${isOpen ? styles.navMenuCardButtonOpen : ''} navbar__link`}
|
||||
onClick={handleToggle}
|
||||
aria-controls={dropdownId}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
className={styles.navMenuCardIcon}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={dropdownId}
|
||||
className={`${styles.navMenuCardDropdown} ${isOpen ? styles.navMenuCardDropdownOpen : ''}`}
|
||||
>
|
||||
<div className={styles.navMenuCardContainer}>
|
||||
{displaySections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className={styles.navMenuCardSection}>
|
||||
{section.title && (
|
||||
<div className={styles.navMenuCardSectionTitle}>{section.title}</div>
|
||||
)}
|
||||
<div className={styles.navMenuCardGrid}>
|
||||
{section.items.map((item, itemIndex) => {
|
||||
const isExternal =
|
||||
item.href &&
|
||||
(item.href.startsWith('http://') || item.href.startsWith('https://'));
|
||||
const linkProps = {
|
||||
to: item.to,
|
||||
href: item.href,
|
||||
className: styles.navMenuCardItem,
|
||||
...(isExternal && { target: '_blank', rel: 'noopener noreferrer' }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Link key={itemIndex} {...linkProps}>
|
||||
<div className={styles.navMenuCardItemTitle}>{item.label}</div>
|
||||
{item.description && (
|
||||
<div className={styles.navMenuCardItemDescription}>{item.description}</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/* Container */
|
||||
.navMenuCard {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.navMenuCardButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: var(--ifm-navbar-item-padding-vertical) var(--ifm-navbar-item-padding-horizontal);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Dropdown icon */
|
||||
.navMenuCardIcon {
|
||||
opacity: 0.4;
|
||||
margin-left: 2px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.navMenuCardButtonOpen .navMenuCardIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Dropdown container - hidden by default */
|
||||
.navMenuCardDropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
padding-top: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Inner dropdown with background and border */
|
||||
.navMenuCardContainer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
gap: 56px;
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
/* Section container */
|
||||
.navMenuCardSection {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Section title */
|
||||
.navMenuCardSectionTitle {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
margin-bottom: 20px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Grid of items within a section */
|
||||
.navMenuCardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Individual menu items */
|
||||
.navMenuCardItem {
|
||||
display: block;
|
||||
padding: 16px 18px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.navMenuCardItem:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
text-decoration: none;
|
||||
color: var(--ifm-font-color-base);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.navMenuCardItemTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--ifm-heading-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.navMenuCardItemDescription {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme='dark'] .navMenuCardContainer {
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.4),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .navMenuCardItem:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.navMenuCardIcon,
|
||||
.navMenuCardDropdown,
|
||||
.navMenuCardItem {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navMenuCardItem:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Open state for mobile (when clicked) */
|
||||
.navMenuCardDropdownOpen {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
/* Hide dropdown menus in the top navbar but keep them for the hamburger menu */
|
||||
:global(.navbar__inner) .navMenuCard {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Style for mobile sidebar/hamburger menu */
|
||||
:global(.navbar-sidebar__items) .navMenuCard {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.navbar-sidebar__items) .navMenuCardButton {
|
||||
color: var(--ifm-menu-color);
|
||||
}
|
||||
|
||||
:global(.navbar-sidebar__items) .navMenuCardButton:hover {
|
||||
color: var(--ifm-menu-color-active);
|
||||
}
|
||||
|
||||
.navMenuCardButton {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.navMenuCardDropdown {
|
||||
position: static;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Show dropdown inline when open, hide when closed */
|
||||
.navMenuCardDropdown {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.navMenuCardDropdownOpen {
|
||||
max-height: 2000px;
|
||||
}
|
||||
|
||||
.navMenuCardContainer {
|
||||
min-width: auto;
|
||||
max-width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.navMenuCardGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navMenuCardItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navMenuCardItemTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.navMenuCardItemDescription {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navMenuCardSectionTitle {
|
||||
font-size: 9px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments for mobile */
|
||||
[data-theme='dark'] .navMenuCardContainer {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.container {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.container :global(.inline-container) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import styles from './NewsletterForm.module.css';
|
||||
|
||||
const NewsletterForm: React.FC = () => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://eocampaign1.com/form/f3ae5b98-452e-11ef-bf73-e58938252fdd.js';
|
||||
script.async = true;
|
||||
script.setAttribute('data-form', 'f3ae5b98-452e-11ef-bf73-e58938252fdd');
|
||||
containerRef.current.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (containerRef.current && script.parentNode === containerRef.current) {
|
||||
containerRef.current.removeChild(script);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
{/* The form will be injected here by the external script */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterForm;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import CodeBlock from '@theme/CodeBlock';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
interface FileDefinition {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface PythonFileViewerProps {
|
||||
files: FileDefinition[];
|
||||
defaultOpen?: string;
|
||||
}
|
||||
|
||||
export default function PythonFileViewer({
|
||||
files,
|
||||
defaultOpen,
|
||||
}: PythonFileViewerProps): React.ReactElement {
|
||||
const [activeFile, setActiveFile] = useState<string | null>(defaultOpen || null);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.headerLabel}>View source</span>
|
||||
<div className={styles.tabs}>
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
className={`${styles.tab} ${activeFile === file.name ? styles.active : ''}`}
|
||||
onClick={() => setActiveFile(activeFile === file.name ? null : file.name)}
|
||||
title={file.description}
|
||||
type="button"
|
||||
>
|
||||
{file.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeFile && (
|
||||
<div className={styles.codeContainer}>
|
||||
<CodeBlock language="python">
|
||||
{files.find((f) => f.name === activeFile)?.content || ''}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
.container {
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: var(--ifm-code-border-radius);
|
||||
overflow: hidden;
|
||||
background: var(--ifm-background-surface-color);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
flex-wrap: wrap;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.headerLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--ifm-color-primary);
|
||||
color: white;
|
||||
border-color: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.tab.active:hover {
|
||||
background: var(--ifm-color-primary-dark);
|
||||
}
|
||||
|
||||
.codeContainer {
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.codeContainer > div {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.codeContainer pre {
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.codeContainer pre code {
|
||||
padding: 0.875rem 1rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme='dark'] .container {
|
||||
border-color: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom-color: var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .headerLabel {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .tab {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .tab:hover {
|
||||
background: var(--ifm-color-emphasis-300);
|
||||
color: var(--ifm-color-emphasis-900);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .tab.active {
|
||||
background: var(--ifm-color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.header {
|
||||
padding: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.headerLabel {
|
||||
font-size: 0.7rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.codeContainer pre code {
|
||||
padding: 0.75rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import React from 'react';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCartContext } from './CartProvider';
|
||||
import { useCopyToClipboard } from './useCopyToClipboard';
|
||||
import { formatPrice, getAttributeName, getCheckoutUrl } from './useFourthwall';
|
||||
|
||||
export function CartDrawer() {
|
||||
const { cart, isCartOpen, closeCart, removeFromCart, updateQuantity, isLoading, couponCode } =
|
||||
useCartContext();
|
||||
const { copied: copiedInDrawer, handleCopy: handleCopyInDrawer } = useCopyToClipboard(couponCode);
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (cart?.id) {
|
||||
// Get currency from first item in cart, or default to USD
|
||||
const currency = cart.items[0]?.variant?.unitPrice?.currency || 'USD';
|
||||
window.location.href = getCheckoutUrl(cart.id, currency);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={isCartOpen}
|
||||
onClose={closeCart}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100%', sm: '400px' },
|
||||
maxWidth: '100vw',
|
||||
// Support safe area insets for notched phones
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: { xs: 1.5, sm: 2 },
|
||||
borderBottom: '1px solid var(--ifm-color-emphasis-300)',
|
||||
minHeight: { xs: 56, sm: 64 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 600, fontSize: { xs: '1.1rem', sm: '1.25rem' } }}
|
||||
>
|
||||
Cart
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={closeCart}
|
||||
aria-label="Close cart"
|
||||
sx={{
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Cart items */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
p: 2,
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{!cart || cart.items.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '260px',
|
||||
gap: 1.5,
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: 'var(--ifm-color-emphasis-600)', fontWeight: 500 }}
|
||||
>
|
||||
Your cart is empty
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={closeCart}
|
||||
sx={{
|
||||
borderColor: 'var(--ifm-color-emphasis-400)',
|
||||
color: 'var(--ifm-font-color-base)',
|
||||
borderRadius: '8px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
'&:hover': {
|
||||
borderColor: 'var(--ifm-color-emphasis-600)',
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Continue shopping
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
pointerEvents: isLoading ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{cart.items.map((item) => {
|
||||
// Get image from variant first, then product, with fallbacks
|
||||
const image = item.variant?.images?.[0] || item.variant?.product?.images?.[0] || null;
|
||||
const productName = item.variant?.product?.name || item.variant?.name || 'Product';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={item.variant.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
pb: 2,
|
||||
borderBottom: '1px solid var(--ifm-color-emphasis-200)',
|
||||
}}
|
||||
>
|
||||
{/* Product image */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<Box
|
||||
component="img"
|
||||
src={image.url}
|
||||
alt={productName}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Product details */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{productName}
|
||||
</Typography>
|
||||
|
||||
{/* Variant attributes */}
|
||||
{item.variant?.attributes &&
|
||||
Object.entries(item.variant.attributes).length > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', display: 'block' }}
|
||||
>
|
||||
{Object.entries(item.variant.attributes)
|
||||
.filter(([key]) => key !== 'description')
|
||||
.map(([_, value]) => getAttributeName(value))
|
||||
.join(' / ')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{item.variant?.unitPrice && (
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
{formatPrice(item.variant.unitPrice)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Quantity controls */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (item.quantity > 1) {
|
||||
updateQuantity(item.variant.id, item.quantity - 1);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || item.quantity <= 1}
|
||||
sx={{
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
p: 0.5,
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<RemoveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="body2" sx={{ minWidth: 28, textAlign: 'center' }}>
|
||||
{item.quantity}
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => updateQuantity(item.variant.id, item.quantity + 1)}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: '4px',
|
||||
p: 0.5,
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => removeFromCart(item.variant.id)}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer with total and checkout */}
|
||||
{cart && cart.items.length > 0 && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid var(--ifm-color-emphasis-300)' }}>
|
||||
{cart.subtotal && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
Subtotal
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{formatPrice(cart.subtotal)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 2 }}>
|
||||
Shipping and taxes calculated at checkout
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{couponCode && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 2,
|
||||
p: 1.5,
|
||||
backgroundColor: 'var(--ifm-color-success-contrast-background)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--ifm-color-success-dark)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ flex: 1, color: 'var(--ifm-color-success-darkest)' }}
|
||||
>
|
||||
Enter code{' '}
|
||||
<Box component="span" sx={{ fontWeight: 700, fontFamily: 'monospace' }}>
|
||||
{couponCode}
|
||||
</Box>{' '}
|
||||
at checkout
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCopyInDrawer}
|
||||
aria-label="Copy promo code"
|
||||
title={copiedInDrawer ? 'Copied!' : 'Copy code'}
|
||||
sx={{ color: 'var(--ifm-color-success-darkest)', p: 0.5 }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: '0.9rem' }} />
|
||||
</IconButton>
|
||||
{copiedInDrawer && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'var(--ifm-color-success-darkest)', fontWeight: 500 }}
|
||||
>
|
||||
Copied!
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={handleCheckout}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
py: { xs: 1.75, sm: 1.5 },
|
||||
minHeight: { xs: 48, sm: 44 },
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
borderRadius: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
touchAction: 'manipulation',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-color-primary-darkest)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} sx={{ color: 'var(--ifm-button-color, #fff)' }} />
|
||||
) : (
|
||||
'Checkout'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { useCart } from './useFourthwall';
|
||||
|
||||
import type { FourthwallCart, FourthwallProduct } from './types';
|
||||
|
||||
const COUPON_STORAGE_KEY = 'promptfoo_coupon_code';
|
||||
|
||||
interface CartContextValue {
|
||||
// Cart state
|
||||
cart: FourthwallCart | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
itemCount: number;
|
||||
|
||||
// Cart actions
|
||||
addToCart: (variantId: string, quantity?: number) => Promise<FourthwallCart | undefined>;
|
||||
removeFromCart: (itemId: string) => Promise<FourthwallCart | undefined>;
|
||||
updateQuantity: (itemId: string, quantity: number) => Promise<FourthwallCart | undefined>;
|
||||
clearCart: () => void;
|
||||
|
||||
// Cart drawer state
|
||||
isCartOpen: boolean;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
|
||||
// Product modal state
|
||||
selectedProduct: FourthwallProduct | null;
|
||||
openProductModal: (product: FourthwallProduct) => void;
|
||||
closeProductModal: () => void;
|
||||
|
||||
// Coupon state
|
||||
couponCode: string | null;
|
||||
clearCoupon: () => void;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
cart,
|
||||
isLoading,
|
||||
error,
|
||||
itemCount,
|
||||
addToCart: addToCartApi,
|
||||
removeFromCart: removeFromCartApi,
|
||||
updateQuantity: updateQuantityApi,
|
||||
clearCart: clearCartApi,
|
||||
} = useCart();
|
||||
|
||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<FourthwallProduct | null>(null);
|
||||
const [couponCode, setCouponCode] = useState<string | null>(null);
|
||||
|
||||
const ingestCouponFromUrl = useCallback(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlCoupon = params.get('coupon');
|
||||
if (urlCoupon) {
|
||||
const code = urlCoupon.trim().toUpperCase();
|
||||
setCouponCode(code);
|
||||
try {
|
||||
localStorage.setItem(COUPON_STORAGE_KEY, code);
|
||||
} catch {
|
||||
// Private browsing
|
||||
}
|
||||
// Clean the URL without triggering a navigation
|
||||
params.delete('coupon');
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState(window.history.state, '', newUrl);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Read coupon from URL or localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!ingestCouponFromUrl()) {
|
||||
// Fall back to localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(COUPON_STORAGE_KEY);
|
||||
if (stored) {
|
||||
setCouponCode(stored);
|
||||
}
|
||||
} catch {
|
||||
// Private browsing
|
||||
}
|
||||
}
|
||||
}, [ingestCouponFromUrl]);
|
||||
|
||||
// Re-check URL on client-side navigations (Docusaurus SPA route changes)
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => ingestCouponFromUrl();
|
||||
|
||||
// Docusaurus uses pushState/replaceState for navigation
|
||||
window.addEventListener('popstate', handleRouteChange);
|
||||
|
||||
// Patch pushState/replaceState to detect Docusaurus client-side navigation
|
||||
const originalPushState = window.history.pushState;
|
||||
const originalReplaceState = window.history.replaceState;
|
||||
window.history.pushState = function (...args) {
|
||||
originalPushState.apply(this, args);
|
||||
handleRouteChange();
|
||||
};
|
||||
window.history.replaceState = function (...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
handleRouteChange();
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handleRouteChange);
|
||||
window.history.pushState = originalPushState;
|
||||
window.history.replaceState = originalReplaceState;
|
||||
};
|
||||
}, [ingestCouponFromUrl]);
|
||||
|
||||
const clearCoupon = useCallback(() => {
|
||||
setCouponCode(null);
|
||||
try {
|
||||
localStorage.removeItem(COUPON_STORAGE_KEY);
|
||||
} catch {
|
||||
// Private browsing
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openCart = useCallback(() => setIsCartOpen(true), []);
|
||||
const closeCart = useCallback(() => setIsCartOpen(false), []);
|
||||
|
||||
const openProductModal = useCallback((product: FourthwallProduct) => {
|
||||
setSelectedProduct(product);
|
||||
}, []);
|
||||
|
||||
const closeProductModal = useCallback(() => {
|
||||
setSelectedProduct(null);
|
||||
}, []);
|
||||
|
||||
// Wrap addToCart to automatically open cart drawer on success
|
||||
const addToCart = useCallback(
|
||||
async (variantId: string, quantity: number = 1) => {
|
||||
const result = await addToCartApi(variantId, quantity);
|
||||
if (result) {
|
||||
setIsCartOpen(true);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[addToCartApi],
|
||||
);
|
||||
|
||||
const value: CartContextValue = {
|
||||
cart,
|
||||
isLoading,
|
||||
error,
|
||||
itemCount,
|
||||
addToCart,
|
||||
removeFromCart: removeFromCartApi,
|
||||
updateQuantity: updateQuantityApi,
|
||||
clearCart: clearCartApi,
|
||||
isCartOpen,
|
||||
openCart,
|
||||
closeCart,
|
||||
selectedProduct,
|
||||
openProductModal,
|
||||
closeProductModal,
|
||||
couponCode,
|
||||
clearCoupon,
|
||||
};
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCartContext() {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) {
|
||||
throw new Error('useCartContext must be used within a CartProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProductCard } from './ProductCard';
|
||||
|
||||
import type { FourthwallProduct } from './types';
|
||||
|
||||
const mockProduct: FourthwallProduct = {
|
||||
id: 'prod-1',
|
||||
slug: 'test-product',
|
||||
name: 'Test Product',
|
||||
description: '<p>A test product description</p>',
|
||||
images: [
|
||||
{ id: 'img-1', url: 'https://example.com/image1.jpg', width: 800, height: 800 },
|
||||
{ id: 'img-2', url: 'https://example.com/image2.jpg', width: 800, height: 800 },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
id: 'var-1',
|
||||
name: 'Default',
|
||||
sku: 'TEST-001',
|
||||
unitPrice: { value: 29.99, currency: 'USD' },
|
||||
stock: { type: 'UNLIMITED' },
|
||||
images: [],
|
||||
attributes: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('renders product image', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
|
||||
const image = screen.getByRole('img', { name: mockProduct.name });
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image).toHaveAttribute('src', mockProduct.images[0].url);
|
||||
});
|
||||
|
||||
it('has accessible label', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: `View ${mockProduct.name}` });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProductCard product={mockProduct} onClick={handleClick} />);
|
||||
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledWith(mockProduct);
|
||||
});
|
||||
|
||||
it('shows skeleton while image loads', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
|
||||
// Primary image should have opacity 0 initially
|
||||
const primaryImage = screen.getByRole('img', { name: mockProduct.name });
|
||||
expect(primaryImage).toHaveStyle({ opacity: 0 });
|
||||
});
|
||||
|
||||
it('shows image after loading', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
|
||||
const primaryImage = screen.getByRole('img', { name: mockProduct.name });
|
||||
fireEvent.load(primaryImage);
|
||||
|
||||
expect(primaryImage).toHaveStyle({ opacity: 1 });
|
||||
});
|
||||
|
||||
it('crossfades to hover image on hover when both images loaded', async () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const primaryImage = screen.getByRole('img', { name: mockProduct.name });
|
||||
const hoverImage = screen.getByRole('img', { name: `${mockProduct.name} alternate view` });
|
||||
|
||||
// Load both images
|
||||
fireEvent.load(primaryImage);
|
||||
fireEvent.load(hoverImage);
|
||||
|
||||
// Initially primary is visible, hover is hidden
|
||||
expect(primaryImage).toHaveStyle({ opacity: 1 });
|
||||
expect(hoverImage).toHaveStyle({ opacity: 0 });
|
||||
|
||||
// Hover shows second image, hides primary
|
||||
fireEvent.mouseEnter(button);
|
||||
expect(primaryImage).toHaveStyle({ opacity: 0 });
|
||||
expect(hoverImage).toHaveStyle({ opacity: 1 });
|
||||
|
||||
// Mouse leave returns to primary
|
||||
fireEvent.mouseLeave(button);
|
||||
expect(primaryImage).toHaveStyle({ opacity: 1 });
|
||||
expect(hoverImage).toHaveStyle({ opacity: 0 });
|
||||
});
|
||||
|
||||
it('displays product name', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('Test Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays product price', () => {
|
||||
render(<ProductCard product={mockProduct} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('$29.99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the lowest price when multiple variants exist', () => {
|
||||
const multiVariantProduct = {
|
||||
...mockProduct,
|
||||
variants: [
|
||||
{ ...mockProduct.variants[0], id: 'v1', unitPrice: { value: 49.99, currency: 'USD' } },
|
||||
{ ...mockProduct.variants[0], id: 'v2', unitPrice: { value: 19.99, currency: 'USD' } },
|
||||
{ ...mockProduct.variants[0], id: 'v3', unitPrice: { value: 39.99, currency: 'USD' } },
|
||||
],
|
||||
};
|
||||
|
||||
render(<ProductCard product={multiVariantProduct} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('$19.99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles product with single image', () => {
|
||||
const singleImageProduct = {
|
||||
...mockProduct,
|
||||
images: [mockProduct.images[0]],
|
||||
};
|
||||
|
||||
render(<ProductCard product={singleImageProduct} onClick={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const image = screen.getByRole('img');
|
||||
|
||||
// Hover should not change image
|
||||
fireEvent.mouseEnter(button);
|
||||
expect(image).toHaveAttribute('src', singleImageProduct.images[0].url);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { formatPrice } from './useFourthwall';
|
||||
|
||||
import type { FourthwallProduct } from './types';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: FourthwallProduct;
|
||||
onClick: (product: FourthwallProduct) => void;
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onClick }: ProductCardProps) {
|
||||
const [primaryLoaded, setPrimaryLoaded] = useState(false);
|
||||
const [hoverLoaded, setHoverLoaded] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const primaryImage = product.images[0];
|
||||
const hoverImage = product.images[1];
|
||||
const hasHoverImage = Boolean(hoverImage);
|
||||
|
||||
const lowestPrice = useMemo(
|
||||
() =>
|
||||
product.variants.reduce(
|
||||
(min, v) => (v.unitPrice.value < min.value ? v.unitPrice : min),
|
||||
product.variants[0]?.unitPrice ?? { value: 0, currency: 'USD' },
|
||||
),
|
||||
[product.variants],
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => onClick(product)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 0.2s ease-out, box-shadow 0.2s ease-out, opacity 0.15s ease-out',
|
||||
// Touch-friendly: prevent zoom and improve tap response
|
||||
touchAction: 'manipulation',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
// Only apply hover effect on devices that support hover
|
||||
'@media (hover: hover)': {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 'var(--store-card-shadow-hover)',
|
||||
},
|
||||
},
|
||||
// Active state for touch feedback
|
||||
'&:active': {
|
||||
opacity: 0.8,
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid var(--ifm-color-primary)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}}
|
||||
aria-label={`View ${product.name}`}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{!primaryLoaded && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
animation="wave"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Primary image — always rendered */}
|
||||
<Box
|
||||
component="img"
|
||||
src={primaryImage?.url}
|
||||
alt={product.name}
|
||||
onLoad={() => setPrimaryLoaded(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: primaryLoaded && !(isHovered && hasHoverImage && hoverLoaded) ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-out',
|
||||
}}
|
||||
/>
|
||||
{/* Hover image — rendered once to preload, shown on hover */}
|
||||
{hasHoverImage && (
|
||||
<Box
|
||||
component="img"
|
||||
src={hoverImage.url}
|
||||
alt={`${product.name} alternate view`}
|
||||
onLoad={() => setHoverLoaded(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: isHovered && hoverLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.3s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.3,
|
||||
color: 'var(--ifm-font-color-base)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
color: 'var(--ifm-color-emphasis-700)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
{formatPrice(lowestPrice)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCartContext } from './CartProvider';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { PromoCard } from './PromoCard';
|
||||
|
||||
import type { FourthwallProduct } from './types';
|
||||
|
||||
interface ProductGridProps {
|
||||
products: FourthwallProduct[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function ProductGrid({ products, isLoading, error }: ProductGridProps) {
|
||||
const { openProductModal } = useCartContext();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
}}
|
||||
>
|
||||
<CircularProgress sx={{ color: 'var(--ifm-color-primary)' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
<Typography color="error" align="center">
|
||||
Failed to load products. Please try again later.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
px: 3,
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" align="center">
|
||||
No products available.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: 'repeat(2, 1fr)',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
lg: 'repeat(4, 1fr)',
|
||||
},
|
||||
gap: { xs: 1.5, sm: 2.5 },
|
||||
maxWidth: '1200px',
|
||||
mx: 'auto',
|
||||
px: { xs: 1.5, sm: 3, md: 4 },
|
||||
py: { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{products.map((product, index) => (
|
||||
<React.Fragment key={product.id}>
|
||||
<ProductCard product={product} onClick={openProductModal} />
|
||||
{/* Insert promo card after 3rd product */}
|
||||
{index === 2 && <PromoCard />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* If less than 3 products, still show promo card at end */}
|
||||
{products.length > 0 && products.length <= 2 && <PromoCard />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useCartContext } from './CartProvider';
|
||||
import {
|
||||
formatPrice,
|
||||
getAttributeName,
|
||||
getAttributeSwatch,
|
||||
isInStock,
|
||||
stripHtml,
|
||||
} from './useFourthwall';
|
||||
|
||||
import type { FourthwallAttributeValue } from './types';
|
||||
|
||||
export function ProductModal() {
|
||||
const { selectedProduct, closeProductModal, addToCart, isLoading } = useCartContext();
|
||||
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string>('');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Touch swipe handling
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchEndX = useRef<number | null>(null);
|
||||
|
||||
const isOpen = selectedProduct !== null;
|
||||
|
||||
// Reset state when product changes
|
||||
useEffect(() => {
|
||||
if (selectedProduct) {
|
||||
setCurrentImageIndex(0);
|
||||
setSelectedVariantId(selectedProduct.variants[0]?.id || '');
|
||||
}
|
||||
}, [selectedProduct]);
|
||||
|
||||
// Get unique attribute options (e.g., sizes, colors)
|
||||
// Skip 'description' as it's not a selectable attribute
|
||||
const attributeOptions = useMemo(() => {
|
||||
if (!selectedProduct) return {};
|
||||
|
||||
const options: Record<string, { name: string; swatch?: string }[]> = {};
|
||||
|
||||
selectedProduct.variants.forEach((variant) => {
|
||||
Object.entries(variant.attributes).forEach(([key, value]) => {
|
||||
// Skip description attribute - it's just a concatenation of other attrs
|
||||
if (key === 'description') return;
|
||||
|
||||
if (!options[key]) {
|
||||
options[key] = [];
|
||||
}
|
||||
|
||||
const name = getAttributeName(value);
|
||||
const swatch = getAttributeSwatch(value);
|
||||
|
||||
// Only add if not already present
|
||||
if (!options[key].some((opt) => opt.name === name)) {
|
||||
options[key].push({ name, swatch });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [selectedProduct]);
|
||||
|
||||
// Current selected variant
|
||||
const selectedVariant = useMemo(() => {
|
||||
return selectedProduct?.variants.find((v) => v.id === selectedVariantId);
|
||||
}, [selectedProduct, selectedVariantId]);
|
||||
|
||||
// Images to show (variant-specific or product-level)
|
||||
const images = useMemo(() => {
|
||||
if (!selectedProduct) return [];
|
||||
// If variant has images, use those; otherwise use product images
|
||||
if (selectedVariant?.images && selectedVariant.images.length > 0) {
|
||||
return selectedVariant.images;
|
||||
}
|
||||
return selectedProduct.images;
|
||||
}, [selectedProduct, selectedVariant]);
|
||||
|
||||
const handlePrevImage = useCallback(() => {
|
||||
setCurrentImageIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
|
||||
}, [images.length]);
|
||||
|
||||
const handleNextImage = useCallback(() => {
|
||||
setCurrentImageIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
|
||||
}, [images.length]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
handlePrevImage();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
handleNextImage();
|
||||
} else if (e.key === 'Escape') {
|
||||
closeProductModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, handlePrevImage, handleNextImage, closeProductModal]);
|
||||
|
||||
// Touch swipe handlers for image carousel
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchEndX.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
touchEndX.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (touchStartX.current === null || touchEndX.current === null) return;
|
||||
|
||||
const swipeDistance = touchStartX.current - touchEndX.current;
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
if (Math.abs(swipeDistance) > minSwipeDistance) {
|
||||
if (swipeDistance > 0) {
|
||||
// Swiped left -> next image
|
||||
handleNextImage();
|
||||
} else {
|
||||
// Swiped right -> previous image
|
||||
handlePrevImage();
|
||||
}
|
||||
}
|
||||
|
||||
touchStartX.current = null;
|
||||
touchEndX.current = null;
|
||||
}, [handleNextImage, handlePrevImage]);
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addToCart(selectedVariantId, 1);
|
||||
closeProductModal();
|
||||
} catch {
|
||||
// Error is handled by the cart context
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Find variant by selected attributes (comparing by name string)
|
||||
const handleAttributeChange = (attributeName: string, newValue: string) => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
// Build a map of current attribute names
|
||||
const currentAttrNames: Record<string, string> = {};
|
||||
if (selectedVariant) {
|
||||
Object.entries(selectedVariant.attributes).forEach(([key, val]) => {
|
||||
if (key !== 'description') {
|
||||
currentAttrNames[key] = getAttributeName(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update with the new value
|
||||
currentAttrNames[attributeName] = newValue;
|
||||
|
||||
// Find a variant that matches all current attribute names
|
||||
const matchingVariant = selectedProduct.variants.find((v) =>
|
||||
Object.entries(currentAttrNames).every(
|
||||
([key, name]) => v.attributes[key] && getAttributeName(v.attributes[key]) === name,
|
||||
),
|
||||
);
|
||||
|
||||
if (matchingVariant) {
|
||||
setSelectedVariantId(matchingVariant.id);
|
||||
} else {
|
||||
// Fallback: find any variant with the selected attribute name
|
||||
const fallbackVariant = selectedProduct.variants.find(
|
||||
(v) =>
|
||||
v.attributes[attributeName] && getAttributeName(v.attributes[attributeName]) === newValue,
|
||||
);
|
||||
if (fallbackVariant) {
|
||||
setSelectedVariantId(fallbackVariant.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedProduct) return null;
|
||||
|
||||
const hasMultipleImages = images.length > 1;
|
||||
const hasVariants = selectedProduct.variants.length > 1;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={closeProductModal}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100vw', sm: '90vw' },
|
||||
maxWidth: { xs: '100vw', sm: '1000px' },
|
||||
height: { xs: '100vh', sm: '85vh' },
|
||||
maxHeight: { xs: '100vh', sm: '800px' },
|
||||
m: 0,
|
||||
borderRadius: { xs: 0, sm: '8px' },
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: 'var(--ifm-font-color-base, #1c1e21)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header bar - OpenAI Supply style */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: { xs: 1.5, sm: 2 },
|
||||
py: 1.5,
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
color: 'var(--ifm-button-color, #fff)',
|
||||
flexShrink: 0,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{selectedProduct.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={closeProductModal}
|
||||
size="small"
|
||||
sx={{
|
||||
color: 'var(--ifm-button-color, #fff)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Content area */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--ifm-background-color, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{/* Image carousel */}
|
||||
<Box
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
sx={{
|
||||
flex: { xs: '0 0 auto', md: '1 1 60%' },
|
||||
position: 'relative',
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: { xs: '250px', sm: '300px', md: 'auto' },
|
||||
maxHeight: { xs: '40vh', md: 'none' },
|
||||
// Improve touch scrolling
|
||||
touchAction: 'pan-y pinch-zoom',
|
||||
cursor: hasMultipleImages ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{images[currentImageIndex] && (
|
||||
<Box
|
||||
component="img"
|
||||
src={images[currentImageIndex].url}
|
||||
alt={`${selectedProduct.name} - Image ${currentImageIndex + 1}`}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{hasMultipleImages && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handlePrevImage}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: 'var(--ifm-background-color, #ffffff)',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
},
|
||||
}}
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextImage}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: 'var(--ifm-background-color, #ffffff)',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
},
|
||||
}}
|
||||
aria-label="Next image"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Image counter */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
color: '#fff',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{currentImageIndex + 1} / {images.length}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Product details */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: { xs: '1 1 auto', md: '1 1 40%' },
|
||||
p: { xs: 2, sm: 3 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* Description */}
|
||||
{selectedProduct.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 3,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{stripHtml(selectedProduct.description)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Variant selectors */}
|
||||
{hasVariants && Object.keys(attributeOptions).length > 0 && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{Object.entries(attributeOptions).map(([attrName, options]) => {
|
||||
// Get current selected value as string
|
||||
const currentValue = selectedVariant?.attributes[attrName]
|
||||
? getAttributeName(selectedVariant.attributes[attrName])
|
||||
: '';
|
||||
|
||||
return (
|
||||
<FormControl key={attrName} fullWidth sx={{ mb: 2 }} size="small">
|
||||
<InputLabel id={`${attrName}-label`}>
|
||||
{attrName.charAt(0).toUpperCase() + attrName.slice(1)}
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId={`${attrName}-label`}
|
||||
value={currentValue}
|
||||
label={attrName.charAt(0).toUpperCase() + attrName.slice(1)}
|
||||
onChange={(e) => handleAttributeChange(attrName, e.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.name} value={option.name}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{option.swatch && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: option.swatch,
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{option.name}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
{selectedVariant ? formatPrice(selectedVariant.unitPrice) : '—'}
|
||||
</Typography>
|
||||
{selectedVariant?.compareAtPrice && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textDecoration: 'line-through',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{formatPrice(selectedVariant.compareAtPrice)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Stock status */}
|
||||
{selectedVariant && !isInStock(selectedVariant.stock) && (
|
||||
<Typography variant="body2" sx={{ color: 'error.main', mb: 2 }}>
|
||||
Out of stock
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Add to cart button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleAddToCart}
|
||||
disabled={
|
||||
!selectedVariant || !isInStock(selectedVariant.stock) || isAdding || isLoading
|
||||
}
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
py: { xs: 1.75, sm: 1.5 },
|
||||
minHeight: { xs: 48, sm: 44 },
|
||||
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
borderRadius: '8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
// Prevent iOS zoom on input
|
||||
touchAction: 'manipulation',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-color-primary-darkest)',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: 'var(--ifm-color-emphasis-300)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isAdding ? (
|
||||
<CircularProgress size={24} sx={{ color: 'var(--ifm-button-color, #fff)' }} />
|
||||
) : selectedVariant && isInStock(selectedVariant.stock) ? (
|
||||
'Add to Cart'
|
||||
) : (
|
||||
'Out of Stock'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { PromoModal } from './PromoModal';
|
||||
|
||||
export function PromoCard() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
borderRadius: '8px',
|
||||
transition: 'transform 0.2s ease-out, box-shadow 0.2s ease-out',
|
||||
touchAction: 'manipulation',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
'@media (hover: hover)': {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 'var(--store-card-shadow-hover)',
|
||||
},
|
||||
},
|
||||
'&:active': {
|
||||
opacity: 0.9,
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid var(--ifm-color-primary)',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}}
|
||||
aria-label="Open source contributor perks - merge a PR to get a discount code"
|
||||
>
|
||||
{/* Gradient area — matches the product card image slot */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background:
|
||||
'linear-gradient(135deg, var(--ifm-color-primary-darkest) 0%, var(--ifm-color-primary-darker) 50%, var(--ifm-color-primary-dark) 100%)',
|
||||
px: 2,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#fff',
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '1.1rem', sm: '1.4rem' },
|
||||
letterSpacing: '-0.02em',
|
||||
lineHeight: 1.2,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
PRs → Perks
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: { xs: '0.7rem', sm: '0.8rem' },
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Ship code, get merch
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Text area — matches the product card name/price slot */}
|
||||
<Box sx={{ px: 1.5, py: 1.25, textAlign: 'left' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.3,
|
||||
color: 'var(--ifm-font-color-base)',
|
||||
}}
|
||||
>
|
||||
Contributor Discount
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
color: 'var(--ifm-color-emphasis-700)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
</Typography>
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
|
||||
<PromoModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import React from 'react';
|
||||
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SvgIcon from '@mui/material/SvgIcon';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
const DISCORD_URL = 'https://discord.gg/promptfoo';
|
||||
const GITHUB_URL = 'https://github.com/promptfoo/promptfoo';
|
||||
|
||||
function DiscordIcon(props: React.ComponentProps<typeof SvgIcon>) {
|
||||
return (
|
||||
<SvgIcon {...props} viewBox="0 0 24 24">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</SvgIcon>
|
||||
);
|
||||
}
|
||||
|
||||
interface PromoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PromoModal({ open, onClose }: PromoModalProps) {
|
||||
const handleDiscordClick = () => {
|
||||
window.open(DISCORD_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const handleGitHubClick = () => {
|
||||
window.open(GITHUB_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100vw', sm: '90vw' },
|
||||
maxWidth: { xs: '100vw', sm: '500px' },
|
||||
height: { xs: '100vh', sm: 'auto' },
|
||||
maxHeight: { xs: '100vh', sm: '90vh' },
|
||||
m: 0,
|
||||
borderRadius: { xs: 0, sm: '8px' },
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: 'var(--ifm-font-color-base, #1c1e21)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: { xs: 1.5, sm: 2 },
|
||||
py: 1.5,
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||||
}}
|
||||
>
|
||||
Contributor Discount
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
},
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
backgroundColor: 'var(--ifm-background-color, #ffffff)',
|
||||
}}
|
||||
>
|
||||
{/* Headline */}
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pt: { xs: 2.5, sm: 3 }, pb: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
letterSpacing: '-0.02em',
|
||||
fontSize: { xs: '1.25rem', sm: '1.5rem' },
|
||||
color: 'var(--ifm-heading-color)',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Merge a PR, get a discount code
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'var(--ifm-color-emphasis-600)',
|
||||
fontSize: { xs: '0.85rem', sm: '0.9rem' },
|
||||
}}
|
||||
>
|
||||
We give back to open source contributors with exclusive store discounts.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Steps */}
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, pb: { xs: 2, sm: 3 } }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
color: 'var(--ifm-color-emphasis-500)',
|
||||
mb: 2,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
How it works
|
||||
</Typography>
|
||||
|
||||
<Box component="ol" sx={{ listStyle: 'none', m: 0, p: 0 }}>
|
||||
{[
|
||||
{
|
||||
title: 'Fork & Fix',
|
||||
description: 'Bug fix, feature, docs — all contributions welcome.',
|
||||
},
|
||||
{
|
||||
title: 'Get Merged',
|
||||
description: 'Work with maintainers to land your PR.',
|
||||
},
|
||||
{
|
||||
title: 'Claim Perks',
|
||||
description: "Drop your merged PR link in Discord #general. We'll DM you a code.",
|
||||
},
|
||||
].map((item, index) => (
|
||||
<Box
|
||||
component="li"
|
||||
key={item.title}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1.5,
|
||||
mb: index < 2 ? 2 : 0,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.7rem',
|
||||
flexShrink: 0,
|
||||
mt: '2px',
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.4,
|
||||
mb: 0.25,
|
||||
color: 'var(--ifm-heading-color)',
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'var(--ifm-color-emphasis-600)',
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Footer CTAs */}
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 2, sm: 3 },
|
||||
borderTop: '1px solid var(--ifm-color-emphasis-300)',
|
||||
backgroundColor: 'var(--ifm-background-color, #ffffff)',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column-reverse', sm: 'row' },
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={handleGitHubClick}
|
||||
startIcon={<GitHubIcon />}
|
||||
sx={{
|
||||
py: { xs: 1.5, sm: 1.25 },
|
||||
minHeight: { xs: 48, sm: 44 },
|
||||
borderColor: 'var(--ifm-color-emphasis-400)',
|
||||
color: 'var(--ifm-font-color-base)',
|
||||
borderRadius: '8px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||
touchAction: 'manipulation',
|
||||
'&:hover': {
|
||||
borderColor: 'var(--ifm-color-emphasis-600)',
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
View Repository
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={handleDiscordClick}
|
||||
startIcon={<DiscordIcon />}
|
||||
sx={{
|
||||
py: { xs: 1.5, sm: 1.25 },
|
||||
minHeight: { xs: 48, sm: 44 },
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
color: '#fff',
|
||||
borderRadius: '8px',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: { xs: '0.8rem', sm: '0.875rem' },
|
||||
touchAction: 'manipulation',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-color-primary-darkest)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Join Discord
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StoreErrorBoundary } from './StoreErrorBoundary';
|
||||
|
||||
// Component that throws an error
|
||||
function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error message');
|
||||
}
|
||||
return <div>Content rendered successfully</div>;
|
||||
}
|
||||
|
||||
describe('StoreErrorBoundary', () => {
|
||||
// Suppress console.error for error boundary tests
|
||||
const originalError = console.error;
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it('renders children when there is no error', () => {
|
||||
render(
|
||||
<StoreErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</StoreErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error UI when child throws', () => {
|
||||
render(
|
||||
<StoreErrorBoundary>
|
||||
<ThrowingComponent shouldThrow={true} />
|
||||
</StoreErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByText(/couldn't load the store/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('recovers when Try Again is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Start with throwing state
|
||||
const { rerender } = render(
|
||||
<StoreErrorBoundary>
|
||||
<ThrowingComponent shouldThrow={true} />
|
||||
</StoreErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
|
||||
// Change to non-throwing state before clicking retry
|
||||
rerender(
|
||||
<StoreErrorBoundary>
|
||||
<ThrowingComponent shouldThrow={false} />
|
||||
</StoreErrorBoundary>,
|
||||
);
|
||||
|
||||
// Click retry button
|
||||
await user.click(screen.getByRole('button', { name: /try again/i }));
|
||||
|
||||
// Should now render the content
|
||||
expect(screen.getByText('Content rendered successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('logs error to console', () => {
|
||||
render(
|
||||
<StoreErrorBoundary>
|
||||
<ThrowingComponent shouldThrow={true} />
|
||||
</StoreErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[Store] Error caught by boundary:',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary for the store page.
|
||||
* Catches rendering errors and displays a fallback UI instead of crashing.
|
||||
*/
|
||||
export class StoreErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
// Log error for debugging (could send to error tracking service)
|
||||
console.error('[Store] Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '50vh',
|
||||
textAlign: 'center',
|
||||
px: 3,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary', maxWidth: 400 }}>
|
||||
We couldn't load the store. This might be a temporary issue.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={this.handleRetry}
|
||||
sx={{
|
||||
mt: 2,
|
||||
backgroundColor: 'var(--ifm-color-primary-darker)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--ifm-color-primary-darkest)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
backgroundColor: 'var(--ifm-background-surface-color)',
|
||||
border: '1px solid var(--ifm-color-emphasis-300)',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.75rem',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
{'\n\n'}
|
||||
{this.state.error.stack}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Components
|
||||
export { CartDrawer } from './CartDrawer';
|
||||
export { CartProvider, useCartContext } from './CartProvider';
|
||||
export { ProductCard } from './ProductCard';
|
||||
export { ProductGrid } from './ProductGrid';
|
||||
export { ProductModal } from './ProductModal';
|
||||
export { PromoCard } from './PromoCard';
|
||||
export { PromoModal } from './PromoModal';
|
||||
export { StoreErrorBoundary } from './StoreErrorBoundary';
|
||||
// Hooks
|
||||
// Utilities
|
||||
export {
|
||||
formatPrice,
|
||||
getAttributeName,
|
||||
getAttributeSwatch,
|
||||
getCheckoutUrl,
|
||||
isInStock,
|
||||
stripHtml,
|
||||
useCart,
|
||||
useCollections,
|
||||
useProduct,
|
||||
useProducts,
|
||||
} from './useFourthwall';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FourthwallAttributeValue,
|
||||
FourthwallCart,
|
||||
FourthwallCartItem,
|
||||
FourthwallCartVariant,
|
||||
FourthwallCollection,
|
||||
FourthwallImage,
|
||||
FourthwallMoney,
|
||||
FourthwallProduct,
|
||||
FourthwallVariant,
|
||||
PaginatedResponse,
|
||||
} from './types';
|
||||
@@ -0,0 +1,123 @@
|
||||
// Fourthwall Storefront API Types
|
||||
|
||||
export interface FourthwallImage {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FourthwallMoney {
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
// Attribute can be a string, or an object with name (and optionally swatch for colors)
|
||||
export interface FourthwallAttribute {
|
||||
name: string;
|
||||
swatch?: string;
|
||||
}
|
||||
|
||||
export type FourthwallAttributeValue = string | FourthwallAttribute;
|
||||
|
||||
export interface FourthwallVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
unitPrice: FourthwallMoney;
|
||||
compareAtPrice?: FourthwallMoney;
|
||||
attributes: Record<string, FourthwallAttributeValue>;
|
||||
stock: {
|
||||
type: 'LIMITED' | 'UNLIMITED';
|
||||
quantity?: number;
|
||||
};
|
||||
images: FourthwallImage[];
|
||||
weight?: {
|
||||
value: number;
|
||||
unit: string;
|
||||
};
|
||||
dimensions?: {
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
unit: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FourthwallProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
status: 'AVAILABLE' | 'UNAVAILABLE';
|
||||
access: 'PUBLIC' | 'PRIVATE';
|
||||
images: FourthwallImage[];
|
||||
variants: FourthwallVariant[];
|
||||
}
|
||||
|
||||
export interface FourthwallCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Cart item structure per OpenAPI spec - variant is nested object, no top-level variantId
|
||||
export interface FourthwallCartItem {
|
||||
variant: FourthwallCartVariant;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// Variant info returned in cart responses (subset of full variant)
|
||||
export interface FourthwallCartVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
sku?: string;
|
||||
unitPrice: FourthwallMoney;
|
||||
compareAtPrice?: FourthwallMoney;
|
||||
attributes?: Record<string, FourthwallAttributeValue>;
|
||||
stock?: {
|
||||
type: 'LIMITED' | 'UNLIMITED';
|
||||
quantity?: number;
|
||||
};
|
||||
images?: FourthwallImage[];
|
||||
product?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
images?: FourthwallImage[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface FourthwallCart {
|
||||
id: string;
|
||||
items: FourthwallCartItem[];
|
||||
checkoutUrl?: string;
|
||||
subtotal?: FourthwallMoney;
|
||||
}
|
||||
|
||||
export interface PagingInfo {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
elementsSize: number;
|
||||
elementsTotal: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
results: T[];
|
||||
paging: PagingInfo;
|
||||
}
|
||||
|
||||
// Store UI State Types
|
||||
export interface CartState {
|
||||
cart: FourthwallCart | null;
|
||||
isLoading: boolean;
|
||||
isOpen: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ProductModalState {
|
||||
product: FourthwallProduct | null;
|
||||
selectedVariantId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useCopyToClipboard(text: string | null) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!text) return;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
},
|
||||
() => {
|
||||
// Clipboard write denied — ignore silently
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { copied, handleCopy };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatPrice,
|
||||
getAttributeName,
|
||||
getAttributeSwatch,
|
||||
getCheckoutUrl,
|
||||
isInStock,
|
||||
stripHtml,
|
||||
} from './useFourthwall';
|
||||
|
||||
describe('formatPrice', () => {
|
||||
it('formats USD prices correctly', () => {
|
||||
expect(formatPrice({ value: 29.99, currency: 'USD' })).toBe('$29.99');
|
||||
});
|
||||
|
||||
it('formats whole dollar amounts', () => {
|
||||
expect(formatPrice({ value: 100, currency: 'USD' })).toBe('$100.00');
|
||||
});
|
||||
|
||||
it('formats other currencies', () => {
|
||||
expect(formatPrice({ value: 50, currency: 'EUR' })).toMatch(/50/);
|
||||
});
|
||||
|
||||
it('handles zero values', () => {
|
||||
expect(formatPrice({ value: 0, currency: 'USD' })).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripHtml', () => {
|
||||
it('returns empty string for falsy input', () => {
|
||||
expect(stripHtml('')).toBe('');
|
||||
expect(stripHtml(null as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('strips basic HTML tags', () => {
|
||||
expect(stripHtml('<p>Hello World</p>')).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('handles nested tags', () => {
|
||||
expect(stripHtml('<div><p><strong>Bold</strong> text</p></div>')).toBe('Bold text');
|
||||
});
|
||||
|
||||
it('removes script tags and content', () => {
|
||||
expect(stripHtml('<p>Safe</p><script>alert("xss")</script><p>Text</p>')).toBe('SafeText');
|
||||
});
|
||||
|
||||
it('removes script tags with attributes', () => {
|
||||
expect(stripHtml('<script type="text/javascript">evil()</script>OK')).toBe('OK');
|
||||
});
|
||||
|
||||
it('removes script tags with whitespace in closing tag', () => {
|
||||
// Edge case: </script > with trailing space
|
||||
expect(stripHtml('<script>bad()</script >Safe')).toBe('Safe');
|
||||
});
|
||||
|
||||
it('removes style tags and content', () => {
|
||||
expect(stripHtml('<style>.red{color:red}</style><p>Text</p>')).toBe('Text');
|
||||
});
|
||||
|
||||
it('removes style tags with whitespace in closing tag', () => {
|
||||
expect(stripHtml('<style>.x{}</style >OK')).toBe('OK');
|
||||
});
|
||||
|
||||
it('decodes HTML entities', () => {
|
||||
expect(stripHtml('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
expect(stripHtml('<not a tag>')).toBe('<not a tag>');
|
||||
expect(stripHtml('"quoted"')).toBe('"quoted"');
|
||||
});
|
||||
|
||||
it('decodes numeric entities', () => {
|
||||
expect(stripHtml('ABC')).toBe('ABC');
|
||||
expect(stripHtml('ABC')).toBe('ABC');
|
||||
});
|
||||
|
||||
it('handles complex product descriptions', () => {
|
||||
const html = `
|
||||
<div class="description">
|
||||
<p>Premium quality <strong>T-Shirt</strong> made from 100% cotton.</p>
|
||||
<ul>
|
||||
<li>Comfortable fit</li>
|
||||
<li>Machine washable</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
const result = stripHtml(html);
|
||||
expect(result).toContain('Premium quality');
|
||||
expect(result).toContain('T-Shirt');
|
||||
expect(result).toContain('100% cotton');
|
||||
expect(result).not.toContain('<');
|
||||
expect(result).not.toContain('>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInStock', () => {
|
||||
it('returns true for unlimited stock', () => {
|
||||
expect(isInStock({ type: 'UNLIMITED' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for limited stock with quantity > 0', () => {
|
||||
expect(isInStock({ type: 'LIMITED', quantity: 5 })).toBe(true);
|
||||
expect(isInStock({ type: 'LIMITED', quantity: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for limited stock with quantity 0', () => {
|
||||
expect(isInStock({ type: 'LIMITED', quantity: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for limited stock without quantity', () => {
|
||||
expect(isInStock({ type: 'LIMITED' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown stock type', () => {
|
||||
expect(isInStock({ type: 'UNKNOWN' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttributeName', () => {
|
||||
it('returns string attributes directly', () => {
|
||||
expect(getAttributeName('Large')).toBe('Large');
|
||||
expect(getAttributeName('Red')).toBe('Red');
|
||||
});
|
||||
|
||||
it('extracts name from object attributes', () => {
|
||||
expect(getAttributeName({ name: 'Blue', swatch: '#0000ff' })).toBe('Blue');
|
||||
expect(getAttributeName({ name: 'Medium' })).toBe('Medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttributeSwatch', () => {
|
||||
it('returns undefined for string attributes', () => {
|
||||
expect(getAttributeSwatch('Large')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns swatch color from object attributes', () => {
|
||||
expect(getAttributeSwatch({ name: 'Blue', swatch: '#0000ff' })).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('returns undefined for object without swatch', () => {
|
||||
expect(getAttributeSwatch({ name: 'Medium' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckoutUrl', () => {
|
||||
it('generates correct checkout URL', () => {
|
||||
const url = getCheckoutUrl('cart-123', 'USD');
|
||||
expect(url).toBe(
|
||||
'https://promptfoo-shop.fourthwall.com/checkout/?cartCurrency=USD&cartId=cart-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to USD currency', () => {
|
||||
const url = getCheckoutUrl('cart-456');
|
||||
expect(url).toContain('cartCurrency=USD');
|
||||
});
|
||||
|
||||
it('handles different currencies', () => {
|
||||
const url = getCheckoutUrl('cart-789', 'EUR');
|
||||
expect(url).toContain('cartCurrency=EUR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
FourthwallAttributeValue,
|
||||
FourthwallCart,
|
||||
FourthwallCollection,
|
||||
FourthwallProduct,
|
||||
PaginatedResponse,
|
||||
} from './types';
|
||||
|
||||
// Public storefront token - this is INTENTIONALLY public and client-facing.
|
||||
// Fourthwall storefront tokens are designed to be exposed in frontend code.
|
||||
// Base64 encoded only to prevent false-positive secret scanner alerts.
|
||||
// Decoded value: ptkn_1be7c822-bfc7-4167-83a6-95619ff7ed7f
|
||||
const STOREFRONT_TOKEN = atob('cHRrbl8xYmU3YzgyMi1iZmM3LTQxNjctODNhNi05NTYxOWZmN2VkN2Y=');
|
||||
const API_BASE = 'https://storefront-api.fourthwall.com/v1';
|
||||
const CHECKOUT_DOMAIN = 'https://promptfoo-shop.fourthwall.com';
|
||||
|
||||
// Maximum pages to fetch (safety limit for pagination)
|
||||
const MAX_PAGES = 50;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Safe localStorage wrapper (handles private browsing mode)
|
||||
function safeLocalStorage() {
|
||||
return {
|
||||
getItem(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Silently fail in private browsing mode
|
||||
}
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Silently fail in private browsing mode
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Input validation helpers
|
||||
function validateVariantId(variantId: string): void {
|
||||
if (!variantId || typeof variantId !== 'string' || variantId.trim() === '') {
|
||||
throw new Error('Invalid variant ID');
|
||||
}
|
||||
}
|
||||
|
||||
function validateQuantity(quantity: number): void {
|
||||
if (
|
||||
typeof quantity !== 'number' ||
|
||||
quantity < 1 ||
|
||||
quantity > 99 ||
|
||||
!Number.isInteger(quantity)
|
||||
) {
|
||||
throw new Error('Quantity must be an integer between 1 and 99');
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = new URL(`${API_BASE}${endpoint}`);
|
||||
url.searchParams.set('storefront_token', STOREFRONT_TOKEN);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch all collections
|
||||
export function useCollections() {
|
||||
const [collections, setCollections] = useState<FourthwallCollection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<PaginatedResponse<FourthwallCollection>>('/collections')
|
||||
.then((data) => setCollections(data.results))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { collections, isLoading, error };
|
||||
}
|
||||
|
||||
// Fetch all products from a collection (handles pagination)
|
||||
export function useProducts(collectionSlug: string = 'all') {
|
||||
const [products, setProducts] = useState<FourthwallProduct[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAllProducts() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const allProducts: FourthwallProduct[] = [];
|
||||
let page = 0;
|
||||
|
||||
// Fetch pages until we get an empty results array
|
||||
while (true) {
|
||||
const response = await apiFetch<{ results: FourthwallProduct[] }>(
|
||||
`/collections/${collectionSlug}/products?page=${page}&size=${PAGE_SIZE}`,
|
||||
);
|
||||
|
||||
if (!response.results || response.results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allProducts.push(...response.results);
|
||||
page++;
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if (page >= MAX_PAGES) {
|
||||
console.warn(
|
||||
`[Store] Pagination limit reached (${MAX_PAGES} pages, ${allProducts.length} products). ` +
|
||||
'Some products may not be displayed. Consider increasing MAX_PAGES if the catalog has grown.',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setProducts(allProducts);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch products');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAllProducts();
|
||||
}, [collectionSlug]);
|
||||
|
||||
return { products, isLoading, error };
|
||||
}
|
||||
|
||||
// Fetch a single product
|
||||
export function useProduct(slug: string | null) {
|
||||
const [product, setProduct] = useState<FourthwallProduct | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setProduct(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
apiFetch<FourthwallProduct>(`/products/${slug}`)
|
||||
.then(setProduct)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
return { product, isLoading, error };
|
||||
}
|
||||
|
||||
// Cart operations
|
||||
const CART_STORAGE_KEY = 'promptfoo_cart_id';
|
||||
const storage = safeLocalStorage();
|
||||
|
||||
export function useCart() {
|
||||
const [cart, setCart] = useState<FourthwallCart | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load existing cart on mount
|
||||
useEffect(() => {
|
||||
const cartId = storage.getItem(CART_STORAGE_KEY);
|
||||
if (cartId) {
|
||||
setIsLoading(true);
|
||||
apiFetch<FourthwallCart>(`/carts/${cartId}`)
|
||||
.then(setCart)
|
||||
.catch(() => {
|
||||
// Cart expired or invalid, clear it
|
||||
storage.removeItem(CART_STORAGE_KEY);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createCart = useCallback(async (variantId: string, quantity: number = 1) => {
|
||||
// Validate inputs
|
||||
validateVariantId(variantId);
|
||||
validateQuantity(quantity);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newCart = await apiFetch<FourthwallCart>('/carts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ variantId, quantity }],
|
||||
}),
|
||||
});
|
||||
storage.setItem(CART_STORAGE_KEY, newCart.id);
|
||||
setCart(newCart);
|
||||
return newCart;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create cart');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addToCart = useCallback(
|
||||
async (variantId: string, quantity: number = 1) => {
|
||||
// Validate inputs
|
||||
validateVariantId(variantId);
|
||||
validateQuantity(quantity);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!cart) {
|
||||
return createCart(variantId, quantity);
|
||||
}
|
||||
|
||||
const updatedCart = await apiFetch<FourthwallCart>(`/carts/${cart.id}/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ variantId, quantity }],
|
||||
}),
|
||||
});
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add to cart');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[cart, createCart],
|
||||
);
|
||||
|
||||
const removeFromCart = useCallback(
|
||||
async (variantId: string) => {
|
||||
if (!cart) return;
|
||||
|
||||
// Validate input
|
||||
validateVariantId(variantId);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// API expects: { items: [{ variantId }] }
|
||||
const updatedCart = await apiFetch<FourthwallCart>(`/carts/${cart.id}/remove`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ variantId }],
|
||||
}),
|
||||
});
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove from cart');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[cart],
|
||||
);
|
||||
|
||||
const updateQuantity = useCallback(
|
||||
async (variantId: string, quantity: number) => {
|
||||
if (!cart) return;
|
||||
|
||||
// Validate inputs
|
||||
validateVariantId(variantId);
|
||||
validateQuantity(quantity);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// API expects: { items: [{ variantId, quantity }] }
|
||||
const updatedCart = await apiFetch<FourthwallCart>(`/carts/${cart.id}/change`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ variantId, quantity }],
|
||||
}),
|
||||
});
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update quantity');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[cart],
|
||||
);
|
||||
|
||||
const clearCart = useCallback(() => {
|
||||
storage.removeItem(CART_STORAGE_KEY);
|
||||
setCart(null);
|
||||
}, []);
|
||||
|
||||
const itemCount = cart?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
|
||||
|
||||
return {
|
||||
cart,
|
||||
isLoading,
|
||||
error,
|
||||
itemCount,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
updateQuantity,
|
||||
clearCart,
|
||||
};
|
||||
}
|
||||
|
||||
// Format price for display (Fourthwall returns value in dollars, not cents)
|
||||
export function formatPrice(money: { value: number; currency: string }): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: money.currency,
|
||||
}).format(money.value);
|
||||
}
|
||||
|
||||
// Common HTML entities for SSR decoding
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
'©': '©',
|
||||
'®': '®',
|
||||
'™': '™',
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip HTML tags from a string using indexOf (no regex for tag stripping).
|
||||
* Uses DOMParser in browser (safe), string-based fallback for SSR.
|
||||
* SSR fallback processes trusted Fourthwall API content only.
|
||||
*/
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
// Browser: Use DOMParser (safe, handles all edge cases)
|
||||
if (typeof document !== 'undefined') {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
// Remove script and style elements before getting textContent
|
||||
doc.querySelectorAll('script, style').forEach((el) => el.remove());
|
||||
return doc.body.textContent || '';
|
||||
}
|
||||
|
||||
// SSR fallback: String-based stripping for trusted Fourthwall API content.
|
||||
// Uses indexOf/substring instead of regex to avoid CodeQL js/bad-tag-filter alerts.
|
||||
// The browser path (above) uses safe DOMParser for client-side rendering.
|
||||
let result = html;
|
||||
|
||||
// Remove script/style tags and contents using indexOf (no regex)
|
||||
for (const tag of ['script', 'style']) {
|
||||
let safety = 0;
|
||||
while (safety++ < 100) {
|
||||
const openTag = result.toLowerCase().indexOf(`<${tag}`);
|
||||
if (openTag === -1) break;
|
||||
const closeTag = result.toLowerCase().indexOf(`</${tag}`, openTag);
|
||||
if (closeTag === -1) break;
|
||||
const closeEnd = result.indexOf('>', closeTag);
|
||||
if (closeEnd === -1) break;
|
||||
result = result.substring(0, openTag) + result.substring(closeEnd + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove remaining HTML tags using indexOf (no regex)
|
||||
let safety = 0;
|
||||
while (safety++ < 1000) {
|
||||
const start = result.indexOf('<');
|
||||
if (start === -1) break;
|
||||
const end = result.indexOf('>', start);
|
||||
if (end === -1) break;
|
||||
result = result.substring(0, start) + result.substring(end + 1);
|
||||
}
|
||||
|
||||
// Decode common HTML entities (using string split/join, not regex)
|
||||
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
||||
result = result.split(entity).join(char);
|
||||
}
|
||||
|
||||
// Decode numeric entities ({ format) - simple parsing without regex
|
||||
let numericSafety = 0;
|
||||
while (numericSafety++ < 500) {
|
||||
const start = result.indexOf('&#');
|
||||
if (start === -1) break;
|
||||
const end = result.indexOf(';', start);
|
||||
if (end === -1 || end - start > 10) break;
|
||||
const numStr = result.substring(start + 2, end);
|
||||
const isHex = numStr.toLowerCase().startsWith('x');
|
||||
const num = isHex ? Number.parseInt(numStr.substring(1), 16) : Number.parseInt(numStr, 10);
|
||||
if (!Number.isNaN(num) && num > 0 && num < 0x10ffff) {
|
||||
result = result.substring(0, start) + String.fromCodePoint(num) + result.substring(end + 1);
|
||||
} else {
|
||||
break; // Invalid entity, stop processing
|
||||
}
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
// Check if variant is in stock
|
||||
export function isInStock(stock: { type: string; quantity?: number }): boolean {
|
||||
if (stock.type === 'UNLIMITED') {
|
||||
return true;
|
||||
}
|
||||
if (stock.type === 'LIMITED' && typeof stock.quantity === 'number') {
|
||||
return stock.quantity > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get display name from attribute value (handles both string and object formats)
|
||||
export function getAttributeName(attr: FourthwallAttributeValue): string {
|
||||
if (typeof attr === 'string') {
|
||||
return attr;
|
||||
}
|
||||
return attr.name;
|
||||
}
|
||||
|
||||
// Get swatch color if available (for color attributes)
|
||||
export function getAttributeSwatch(attr: FourthwallAttributeValue): string | undefined {
|
||||
if (typeof attr === 'object' && 'swatch' in attr) {
|
||||
return attr.swatch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Generate checkout URL for a cart
|
||||
export function getCheckoutUrl(cartId: string, currency: string = 'USD'): string {
|
||||
return `${CHECKOUT_DOMAIN}/checkout/?cartCurrency=${currency}&cartId=${cartId}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import generatedStats from './.generated-stats.json';
|
||||
import siteStats from './site-stats.json';
|
||||
|
||||
export const SITE_CONSTANTS = { ...siteStats, ...generatedStats };
|
||||
@@ -0,0 +1,104 @@
|
||||
/* Minimal text-link pagination */
|
||||
|
||||
.pagination-nav {
|
||||
margin-top: 3rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--ifm-color-emphasis-200);
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pagination-nav__item {
|
||||
max-width: 48%;
|
||||
}
|
||||
|
||||
/* Push "next" to the right when it's the only item */
|
||||
.pagination-nav__link--next:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Strip card styling — plain text links */
|
||||
.pagination-nav__link {
|
||||
border: none;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.pagination-nav__link:hover {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Sublabel: "Previous" / "Next" */
|
||||
.pagination-nav__sublabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
/* Page title label */
|
||||
.pagination-nav__label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--ifm-color-primary);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.pagination-nav__link:hover .pagination-nav__label {
|
||||
color: var(--ifm-color-primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Alignment */
|
||||
.pagination-nav__link--next {
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pagination-nav__link--prev {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Remove the arrow pseudo-elements */
|
||||
.pagination-nav__link--next::after,
|
||||
.pagination-nav__link--prev::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.pagination-nav {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-nav__item {
|
||||
max-width: 49%;
|
||||
}
|
||||
|
||||
.pagination-nav__label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme='dark'] .pagination-nav {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .pagination-nav__sublabel {
|
||||
color: var(--ifm-color-emphasis-500);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* Self-hosted Inter variable font (GDPR: avoids Google Fonts CDN) */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url('/fonts/Inter-Variable-Latin.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
/* Primary red color palette */
|
||||
--ifm-color-primary: #e53a3a;
|
||||
--ifm-color-primary-dark: #cb3434;
|
||||
--ifm-color-primary-darker: #b32e2e;
|
||||
--ifm-color-primary-darkest: #991515;
|
||||
--ifm-color-primary-light: #ff7a7a;
|
||||
--ifm-color-primary-lighter: #ffa0a0;
|
||||
--ifm-color-primary-lightest: #ffc6c6;
|
||||
|
||||
/* Custom palette */
|
||||
--pf-red-base: #e53a3a;
|
||||
--pf-red-dark: #cb3434;
|
||||
--pf-red-darker: #b32e2e;
|
||||
--pf-red-light: #ff7a7a;
|
||||
--pf-red-lighter: #ffa0a0;
|
||||
--pf-red-lightest: #ffc6c6;
|
||||
--pf-red-ultra-light: #ffd9d9;
|
||||
|
||||
--pf-dark-base: #2e3c46;
|
||||
--pf-dark-darker: #17252b;
|
||||
--pf-dark-darkest: #10191c;
|
||||
|
||||
--ifm-code-font-size: 95%;
|
||||
--ifm-navbar-collapse-breakpoint: 1100px;
|
||||
--ifm-font-family-base:
|
||||
'Inter', system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif,
|
||||
BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
--docusaurus-highlighted-code-line-bg: #fffbdc;
|
||||
|
||||
/* Store page tokens */
|
||||
--store-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
--store-card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
:root[data-theme='dark'] {
|
||||
--ifm-color-primary: #ff7a7a;
|
||||
--ifm-color-primary-dark: #e53a3a;
|
||||
--ifm-color-primary-darker: #cb3434;
|
||||
--ifm-color-primary-darkest: #b32e2e;
|
||||
--ifm-color-primary-light: #ffa0a0;
|
||||
--ifm-color-primary-lighter: #ffc6c6;
|
||||
--ifm-color-primary-lightest: #ffd9d9;
|
||||
--ifm-button-color: #fff;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Update background colors */
|
||||
--ifm-background-color: #10191c;
|
||||
--ifm-background-surface-color: #17252b;
|
||||
|
||||
/* Store page tokens */
|
||||
--store-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
--store-card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
code p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero--primary {
|
||||
--ifm-hero-background-color: var(--ifm-background-color);
|
||||
--ifm-hero-text-color: var(--ifm-heading-color);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.theme-doc-sidebar-item-category-level-1 > div {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar__brand {
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide search bar on non-docs pages */
|
||||
html:not(.docs-wrapper) .navbar__search,
|
||||
html:not(.docs-wrapper) .DocSearch {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Scalar API Reference search modal - Safari fixes */
|
||||
#app div.scalar-modal.scalar-modal-search.bg-b-1,
|
||||
#app div.scalar-modal-body {
|
||||
background-color: white !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] #app div.scalar-modal.scalar-modal-search.bg-b-1,
|
||||
[data-theme='dark'] #app div.scalar-modal-body {
|
||||
background-color: #1a1a1a !important;
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Mobile navbar adjustments */
|
||||
@media (max-width: 1100px) {
|
||||
/* Hide search box on mobile to prevent overlap */
|
||||
.navbar__search {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Keep hamburger top-level items consistent */
|
||||
.navbar-sidebar__items .menu__list > .menu__list-item > .menu__link,
|
||||
.navbar-sidebar__items .header-book-demo-link,
|
||||
.navbar-sidebar__items .header-discord-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.navbar-sidebar__items .menu__list > .menu__list-item > .menu__link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-sidebar__items .header-discord-link {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.navbar-sidebar__items .header-discord-link::after {
|
||||
content: 'Discord';
|
||||
}
|
||||
|
||||
.docs-wrapper .DocSearch-Button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.docs-wrapper .DocSearch-Button-Placeholder,
|
||||
.docs-wrapper .DocSearch-Button-Keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__logo img {
|
||||
filter: invert(0);
|
||||
}
|
||||
|
||||
.header-github-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-github-link::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header-github-link::before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.header-discord-link:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-discord-link::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header-discord-link::before {
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z'/%3E%3C/svg%3E")
|
||||
no-repeat;
|
||||
}
|
||||
|
||||
.navbar__items--right > :nth-last-child(2) {
|
||||
/* Equalize the spacing to the left of the color mode toggle */
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.docs-wrapper .navbar__items--right .header-discord-link {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown > .navbar__link:after {
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
|
||||
no-repeat center;
|
||||
border: none;
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
margin-left: 2px;
|
||||
transform: none;
|
||||
opacity: 0.4;
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
/* Book a Demo button styling */
|
||||
.header-book-demo-link {
|
||||
background-color: var(--pf-red-base);
|
||||
color: white !important;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.header-book-demo-link:hover {
|
||||
background-color: var(--pf-red-dark);
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(229, 58, 58, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header-book-demo-link {
|
||||
background-color: var(--pf-red-light);
|
||||
color: var(--pf-dark-darkest) !important;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .header-book-demo-link:hover {
|
||||
background-color: var(--pf-red-lighter);
|
||||
color: var(--pf-dark-darkest) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 122, 122, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile adjustments for book demo button */
|
||||
@media (max-width: 1100px) {
|
||||
.header-book-demo-link {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sidebar refinements ── */
|
||||
|
||||
/* Subtle off-white background for the sidebar */
|
||||
.theme-doc-sidebar-container {
|
||||
background: #f8f9fa;
|
||||
border-right: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .theme-doc-sidebar-container {
|
||||
background: var(--ifm-background-surface-color);
|
||||
border-right-color: #1e2d33;
|
||||
}
|
||||
|
||||
/* Tighter vertical spacing on sidebar items */
|
||||
.menu__link {
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
/* Active item: colored left border accent */
|
||||
.menu__link--active {
|
||||
background: var(--pf-red-ultra-light);
|
||||
border-left: 3px solid var(--pf-red-base);
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .menu__link--active {
|
||||
background: rgba(255, 122, 122, 0.1);
|
||||
border-left-color: var(--pf-red-light);
|
||||
}
|
||||
|
||||
/* When a child is also active, suppress the parent category accent */
|
||||
.menu__list-item:has(> .menu__list .menu__link--active) > div > .menu__link--active,
|
||||
.menu__list-item:has(> .menu__list .menu__link--active) > .menu__link--active {
|
||||
background: none;
|
||||
border-left: none;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.menu__link:hover:not(.menu__link--active) {
|
||||
background: #eef0f2;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .menu__link:hover:not(.menu__link--active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse transition for categories */
|
||||
.menu__list {
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Admonition refinements ── */
|
||||
|
||||
/* Base: strip default Docusaurus/Infima alert styling */
|
||||
.theme-admonition {
|
||||
border: none;
|
||||
border-left: 3px solid;
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: none;
|
||||
--ifm-alert-padding-horizontal: 1.25rem;
|
||||
--ifm-alert-padding-vertical: 1rem;
|
||||
}
|
||||
|
||||
/* Heading: uppercase small label */
|
||||
[class*='admonitionHeading'] {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Icon: slightly smaller */
|
||||
[class*='admonitionIcon'] svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Content: tighter typography */
|
||||
[class*='admonitionContent'] > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Per-type colors ── */
|
||||
|
||||
/* Note (gray) */
|
||||
.theme-admonition-note {
|
||||
border-left-color: #868e96;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
[data-theme='dark'] .theme-admonition-note {
|
||||
border-left-color: #adb5bd;
|
||||
background: rgba(173, 181, 189, 0.08);
|
||||
}
|
||||
|
||||
/* Tip (green) */
|
||||
.theme-admonition-tip {
|
||||
border-left-color: #2f9e44;
|
||||
background: #ebfbee;
|
||||
}
|
||||
[data-theme='dark'] .theme-admonition-tip {
|
||||
border-left-color: #51cf66;
|
||||
background: rgba(47, 158, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Info (blue) */
|
||||
.theme-admonition-info,
|
||||
.theme-admonition-important {
|
||||
border-left-color: #1971c2;
|
||||
background: #e7f5ff;
|
||||
}
|
||||
[data-theme='dark'] .theme-admonition-info,
|
||||
[data-theme='dark'] .theme-admonition-important {
|
||||
border-left-color: #4dabf7;
|
||||
background: rgba(25, 113, 194, 0.08);
|
||||
}
|
||||
|
||||
/* Warning / Caution (amber) */
|
||||
.theme-admonition-warning,
|
||||
.theme-admonition-caution {
|
||||
border-left-color: #e8590c;
|
||||
background: #fff4e6;
|
||||
}
|
||||
[data-theme='dark'] .theme-admonition-warning,
|
||||
[data-theme='dark'] .theme-admonition-caution {
|
||||
border-left-color: #ff922b;
|
||||
background: rgba(232, 89, 12, 0.08);
|
||||
}
|
||||
|
||||
/* Danger (red) */
|
||||
.theme-admonition-danger {
|
||||
border-left-color: #c92a2a;
|
||||
background: #fff5f5;
|
||||
}
|
||||
[data-theme='dark'] .theme-admonition-danger {
|
||||
border-left-color: #ff6b6b;
|
||||
background: rgba(201, 42, 42, 0.08);
|
||||
}
|
||||
|
||||
/* Add breathing room between content and TOC */
|
||||
.theme-doc-toc-desktop {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Constrain doc content area for readability */
|
||||
[class*='docMainContainer'] > .container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.llmJudgeFigure {
|
||||
max-width: 1120px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.llmJudgeFigure img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.redteamPolicyScreenshotLink {
|
||||
display: block;
|
||||
margin: 1rem 0 1.5rem;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.redteamPolicyScreenshotLink:hover,
|
||||
.redteamPolicyScreenshotLink:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.redteamPolicyScreenshotLink:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.redteamPolicyScreenshotLink:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.redteamPolicyScreenshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .redteamPolicyScreenshot {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotFigure {
|
||||
margin: 1.25rem 0 1.75rem;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotFigure figcaption {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotLink {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotLink:hover,
|
||||
.redteamPluginScreenshotLink:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotLink:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotLink:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .redteamPluginScreenshot {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.redteamPolicyScreenshotLink {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.redteamPolicyScreenshot {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotFigure {
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshotLink {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.redteamPluginScreenshot {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove pill background from active breadcrumb item */
|
||||
.breadcrumbs__item--active .breadcrumbs__link {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom on input focus within MUI overlays */
|
||||
[class*='MuiDrawer'] input,
|
||||
[class*='MuiDrawer'] select,
|
||||
[class*='MuiDrawer'] textarea,
|
||||
[class*='MuiDialog'] input,
|
||||
[class*='MuiDialog'] select,
|
||||
[class*='MuiDialog'] textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Print styles — hide MUI overlays */
|
||||
@media print {
|
||||
.MuiDrawer-root,
|
||||
.MuiDialog-root,
|
||||
.MuiFab-root {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import custom pagination styles */
|
||||
@import url('./custom-pagination.css');
|
||||
|
||||
/* Announcement bar link styling */
|
||||
div[class*='announcementBar'] a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 2px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
div[class*='announcementBar'] a:hover {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
// Event Types
|
||||
export type EventStatus = 'upcoming' | 'past';
|
||||
export type EventType = 'conference' | 'webinar' | 'workshop' | 'party';
|
||||
|
||||
export interface EventSpeaker {
|
||||
name: string;
|
||||
title: string;
|
||||
photo?: string;
|
||||
linkedin?: string;
|
||||
}
|
||||
|
||||
export interface EventSession {
|
||||
title: string;
|
||||
description: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
speakers: EventSpeaker[];
|
||||
recording?: string;
|
||||
slides?: string;
|
||||
}
|
||||
|
||||
export interface EventDemo {
|
||||
title: string;
|
||||
description: string;
|
||||
schedule?: string;
|
||||
}
|
||||
|
||||
export interface EventResource {
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'pdf' | 'video' | 'link' | 'report';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EventHighlight {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EventLocation {
|
||||
venue: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
status: EventStatus;
|
||||
type: EventType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
location: EventLocation;
|
||||
booth?: string;
|
||||
description: string;
|
||||
fullDescription?: string;
|
||||
heroImage?: string;
|
||||
cardImage?: string;
|
||||
highlights?: EventHighlight[];
|
||||
demos?: EventDemo[];
|
||||
sessions?: EventSession[];
|
||||
teamMembers?: EventSpeaker[];
|
||||
registrationUrl?: string;
|
||||
meetingUrl?: string;
|
||||
photos?: string[];
|
||||
resources?: EventResource[];
|
||||
externalLinks?: { label: string; url: string }[];
|
||||
customPageUrl?: string; // Custom dedicated page URL for special events
|
||||
}
|
||||
|
||||
// Helper to determine event status based on date
|
||||
function getEventStatus(endDate: string): EventStatus {
|
||||
const today = new Date();
|
||||
const eventEnd = new Date(endDate);
|
||||
return eventEnd < today ? 'past' : 'upcoming';
|
||||
}
|
||||
|
||||
// Event Data
|
||||
export const events: Event[] = [
|
||||
// Future Events (2026)
|
||||
{
|
||||
id: 'bsides-seattle-2026',
|
||||
slug: 'bsides-seattle-2026',
|
||||
name: 'Promptfoo at BSides Seattle 2026',
|
||||
shortName: 'BSides Seattle 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-02-27T09:00:00-08:00', // PST
|
||||
endDate: '2026-02-28T18:00:00-08:00', // PST
|
||||
location: {
|
||||
venue: 'Building 92 (Microsoft Campus)',
|
||||
city: 'Redmond',
|
||||
state: 'WA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Meet the Promptfoo team at BSides Seattle for hands-on AI red teaming demos, hallway-track threat intel, and practical ways to harden LLM apps.',
|
||||
fullDescription:
|
||||
'Meet the Promptfoo team for live demos of AI red teaming: prompt injection, jailbreaks, and data exfiltration against real-world LLM apps. Bring your use case and leave with a testing plan you can run in CI.',
|
||||
cardImage: '/img/events/bsides-seattle-2026.jpg',
|
||||
heroImage: '/img/events/bsides-seattle-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🌲',
|
||||
title: 'PNW Community',
|
||||
description: 'Connect with Seattle security pros',
|
||||
},
|
||||
{
|
||||
icon: '🛠️',
|
||||
title: 'Workshops',
|
||||
description: 'Hands-on AI security training',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Networking',
|
||||
description: 'Meet security researchers',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/bsides-seattle-2026',
|
||||
},
|
||||
{
|
||||
id: 'rsa-2026',
|
||||
slug: 'rsa-2026',
|
||||
name: 'Promptfoo at RSA Conference 2026',
|
||||
shortName: 'RSA 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-03-23T09:00:00-07:00', // PDT
|
||||
endDate: '2026-03-26T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Moscone Center',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Meet Promptfoo at RSAC for executive-ready AI security: red teaming, guardrails, and reporting your leadership can act on.',
|
||||
fullDescription:
|
||||
'Meet Promptfoo to see how security teams build an AI security program that scales: continuous red teaming, runtime guardrails, and reporting that tracks risk reduction over time.',
|
||||
cardImage: '/img/events/rsa-2026.jpg',
|
||||
heroImage: '/img/events/rsa-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Live Demos',
|
||||
description: 'Watch AI red teaming attacks in real-time',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Free Assessment',
|
||||
description: 'Get a complimentary AI vulnerability assessment',
|
||||
},
|
||||
{
|
||||
icon: '🎁',
|
||||
title: 'Exclusive Swag',
|
||||
description: 'Limited edition Promptfoo gear',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/rsa-2026',
|
||||
},
|
||||
{
|
||||
id: 'bsides-sf-2026',
|
||||
slug: 'bsides-sf-2026',
|
||||
name: 'Promptfoo at BSides SF 2026',
|
||||
shortName: 'BSides SF 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-03-21T09:00:00-07:00', // PDT
|
||||
endDate: '2026-03-22T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'City View at the Metreon',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Practitioner-led AI security during RSA week. Fast demos, deep conversations, and concrete red teaming takeaways.',
|
||||
fullDescription:
|
||||
'Stop by to compare notes on prompt injection, agent abuse, and the testing workflows security teams actually run. We show quick demos, then go deep on how to reproduce issues and prevent regressions.',
|
||||
cardImage: '/img/events/bsides-sf-2026.jpg',
|
||||
heroImage: '/img/events/bsides-sf-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🛠️',
|
||||
title: 'Workshops',
|
||||
description: 'Hands-on AI security training',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Community',
|
||||
description: 'Connect with security researchers',
|
||||
},
|
||||
{
|
||||
icon: '🏆',
|
||||
title: 'Challenges',
|
||||
description: 'AI red teaming CTF challenges',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/bsides-sf-2026',
|
||||
},
|
||||
{
|
||||
id: 'humanx-2026',
|
||||
slug: 'humanx-2026',
|
||||
name: 'Promptfoo at HumanX 2026',
|
||||
shortName: 'HumanX 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-04-06T09:00:00-07:00', // PDT
|
||||
endDate: '2026-04-09T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Moscone Center South',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'For AI leaders shipping real products: see how to evaluate and secure LLM apps and agents without slowing teams down.',
|
||||
fullDescription:
|
||||
'AI is moving fast. Security and evaluation need to keep up. Meet Promptfoo for live demos on testing and securing LLM features across copilots, RAG, and agents, before launch and continuously in production.',
|
||||
cardImage: '/img/events/humanx-2026.jpg',
|
||||
heroImage: '/img/events/humanx-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'AI Leadership',
|
||||
description: 'Connect with AI executives and innovators',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Live Demos',
|
||||
description: 'See AI security testing in action',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Networking',
|
||||
description: 'Meet enterprise AI teams',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/humanx-2026',
|
||||
},
|
||||
{
|
||||
id: 'gartner-security-2026',
|
||||
slug: 'gartner-security-2026',
|
||||
name: 'Promptfoo at Gartner Security & Risk Management Summit 2026',
|
||||
shortName: 'Gartner Security 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-06-01T09:00:00-04:00', // EDT
|
||||
endDate: '2026-06-03T18:00:00-04:00', // EDT
|
||||
location: {
|
||||
venue: 'Gaylord National Resort & Convention Center',
|
||||
city: 'National Harbor',
|
||||
state: 'MD',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Turn AI risk into a measurable program. Meet Promptfoo for briefings on continuous red teaming, guardrails, and executive reporting.',
|
||||
fullDescription:
|
||||
'If you are building an AI security program, we can help you move from ad hoc testing to continuous coverage. Meet Promptfoo for demos of automated red teaming, runtime guardrails, and reporting security leadership can track.',
|
||||
cardImage: '/img/events/gartner-security-2026.jpg',
|
||||
heroImage: '/img/events/gartner-security-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Analyst Briefings',
|
||||
description: 'Meet with Gartner analysts',
|
||||
},
|
||||
{
|
||||
icon: '🏢',
|
||||
title: 'Enterprise Focus',
|
||||
description: 'Solutions for large organizations',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Risk Management',
|
||||
description: 'AI governance and compliance',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/gartner-security-2026',
|
||||
},
|
||||
{
|
||||
id: 'blackhat-2026',
|
||||
slug: 'blackhat-2026',
|
||||
name: 'Promptfoo at Black Hat USA 2026',
|
||||
shortName: 'Black Hat 2026',
|
||||
status: 'upcoming',
|
||||
type: 'conference',
|
||||
startDate: '2026-08-01T09:00:00-07:00', // PDT
|
||||
endDate: '2026-08-06T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Mandalay Bay Convention Center',
|
||||
city: 'Las Vegas',
|
||||
state: 'NV',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'See live AI attack demos at Black Hat USA: prompt injection, jailbreaks, data exfiltration, and how to automate LLM red teaming at scale.',
|
||||
fullDescription:
|
||||
'Join us at Black Hat USA to see how Promptfoo helps security teams find and fix LLM vulnerabilities with automated red teaming, repeatable evals, and production guardrails.',
|
||||
cardImage: '/img/events/blackhat-2026.jpg',
|
||||
heroImage: '/img/events/blackhat-2026.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Attack Demos',
|
||||
description: 'Prompt injection, jailbreaks, data exfiltration',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Red Team Automation',
|
||||
description: 'Generate application-specific attack variants',
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'CI/CD Integration',
|
||||
description: 'Turn findings into regression tests',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/blackhat-2026',
|
||||
},
|
||||
|
||||
// Past Events (2025)
|
||||
{
|
||||
id: 'scaleup-ai-2025',
|
||||
slug: 'scaleup-ai-2025',
|
||||
name: 'Promptfoo at ScaleUp:AI 2025',
|
||||
shortName: 'ScaleUp:AI 2025',
|
||||
status: 'past',
|
||||
type: 'webinar',
|
||||
startDate: '2025-12-03T09:00:00-05:00', // EST
|
||||
endDate: '2025-12-03T18:00:00-05:00', // EST
|
||||
location: {
|
||||
venue: 'Insight Partners',
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
"Featured in Insight Partners' ScaleUp:AI 2025 Partner Series. CEO Ian Webster discusses how Promptfoo is restoring trust and security in generative AI.",
|
||||
fullDescription:
|
||||
"Ian Webster, CEO and co-founder of Promptfoo, was featured in Insight Partners' ScaleUp:AI 2025 Partner Series, discussing how Promptfoo is defining the standard for enterprise AI security. The feature explores the company's journey from open-source tool to serving 200,000+ developers and 80+ Fortune 500 companies.",
|
||||
cardImage: '/img/events/scaleup-ai-2025.jpg',
|
||||
heroImage: '/img/events/scaleup-ai-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🔐',
|
||||
title: 'AI Security',
|
||||
description: 'Closing the gap in AI defenses',
|
||||
},
|
||||
{
|
||||
icon: '📈',
|
||||
title: 'Growth Story',
|
||||
description: 'From open source to enterprise',
|
||||
},
|
||||
{
|
||||
icon: '🔮',
|
||||
title: 'Future Vision',
|
||||
description: 'Multi-agent security landscape',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/scaleup-ai-2025',
|
||||
externalLinks: [
|
||||
{
|
||||
label: 'Read Full Article',
|
||||
url: 'https://www.insightpartners.com/ideas/promptfoo-scale-up-ai/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blackhat-2025',
|
||||
slug: 'blackhat-2025',
|
||||
name: 'Promptfoo at Black Hat USA 2025',
|
||||
shortName: 'Black Hat 2025',
|
||||
status: getEventStatus('2025-08-07T18:00:00-07:00'),
|
||||
type: 'conference',
|
||||
startDate: '2025-08-05T09:00:00-07:00', // PDT
|
||||
endDate: '2025-08-07T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Mandalay Bay Convention Center',
|
||||
city: 'Las Vegas',
|
||||
state: 'NV',
|
||||
country: 'USA',
|
||||
},
|
||||
booth: 'Booth #4712',
|
||||
description: 'Meet us at booth #4712 for live AI red teaming demos and security consultations.',
|
||||
fullDescription:
|
||||
'Join us at Black Hat USA 2025 for live AI red teaming demos, security consultations, and the latest in LLM vulnerability research. Visit our booth to see how Fortune 500 companies protect their AI applications.',
|
||||
cardImage: '/img/events/blackhat-2025.jpg',
|
||||
heroImage: '/img/events/blackhat-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Live Demos',
|
||||
description: 'Watch AI red teaming attacks in real-time',
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Free Scan',
|
||||
description: 'Get a complimentary AI vulnerability assessment',
|
||||
},
|
||||
{
|
||||
icon: '🎁',
|
||||
title: 'Exclusive Swag',
|
||||
description: 'Limited edition Promptfoo gear',
|
||||
},
|
||||
],
|
||||
demos: [
|
||||
{
|
||||
title: 'LLM Red Teaming Demo',
|
||||
description:
|
||||
'Watch our team attempt to jailbreak and exploit a live AI application using prompt injection, data exfiltration, and other OWASP Top 10 attacks.',
|
||||
schedule: 'Running every 30 minutes at the booth',
|
||||
},
|
||||
],
|
||||
externalLinks: [
|
||||
{
|
||||
label: 'Arsenal Labs - Aug 6',
|
||||
url: 'https://www.blackhat.com/us-25/arsenal/schedule/index.html#promptfoo-44648',
|
||||
},
|
||||
{
|
||||
label: 'Arsenal Labs - Aug 7',
|
||||
url: 'https://www.blackhat.com/us-25/arsenal/schedule/#promptfoo-47875',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/blackhat-2025',
|
||||
},
|
||||
{
|
||||
id: 'defcon-2025',
|
||||
slug: 'defcon-2025',
|
||||
name: 'Promptfoo Party at DEF CON 33',
|
||||
shortName: 'DEF CON 33',
|
||||
status: getEventStatus('2025-08-09T23:59:00-07:00'),
|
||||
type: 'party',
|
||||
startDate: '2025-08-09T20:00:00-07:00', // PDT
|
||||
endDate: '2025-08-09T23:59:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Millennium FANDOM Bar',
|
||||
city: 'Las Vegas',
|
||||
state: 'NV',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Join hackers, security researchers, and the open source community for the AI security party of DEF CON.',
|
||||
fullDescription:
|
||||
"Join hackers, security researchers, and the open source community for the AI security event of DEF CON at the galaxy's most iconic cantina. Free drinks, great vibes, and security war stories.",
|
||||
cardImage: '/img/events/defcon-2025.jpg',
|
||||
heroImage: '/img/events/defcon-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🍺',
|
||||
title: 'Open Bar',
|
||||
description: 'Free drinks on us',
|
||||
},
|
||||
{
|
||||
icon: '⚔️',
|
||||
title: 'Mos Eisley Vibes',
|
||||
description: 'Party in a wretched hive of scum and villainy',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Community',
|
||||
description: 'Network with security researchers',
|
||||
},
|
||||
],
|
||||
registrationUrl: 'https://lu.ma/ljm23pj6?tk=qGE9ez&utm_source=pf-web',
|
||||
customPageUrl: '/events/defcon-2025',
|
||||
},
|
||||
{
|
||||
id: 'rsa-2025',
|
||||
slug: 'rsa-2025',
|
||||
name: 'Promptfoo at RSA Conference 2025',
|
||||
shortName: 'RSA 2025',
|
||||
status: 'past',
|
||||
type: 'conference',
|
||||
startDate: '2025-04-28T09:00:00-07:00', // PDT
|
||||
endDate: '2025-05-01T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Moscone Center',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
booth: 'Expo Floor',
|
||||
description:
|
||||
'We showcased AI red teaming capabilities and security solutions at RSA Conference 2025.',
|
||||
fullDescription:
|
||||
'Promptfoo was at RSA Conference 2025 on the Expo Floor. We demonstrated our AI red teaming platform and connected with security professionals about protecting LLM applications.',
|
||||
cardImage: '/img/events/rsa-2025.jpg',
|
||||
heroImage: '/img/events/rsa-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Live Demos',
|
||||
description: 'AI red teaming demonstrations',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Networking',
|
||||
description: 'Connected with security leaders',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Research',
|
||||
description: 'Shared latest security findings',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/rsa-2025',
|
||||
},
|
||||
{
|
||||
id: 'bsides-sf-2025',
|
||||
slug: 'bsides-sf-2025',
|
||||
name: 'Promptfoo at BSides SF 2025',
|
||||
shortName: 'BSides SF 2025',
|
||||
status: 'past',
|
||||
type: 'conference',
|
||||
startDate: '2025-04-26T09:00:00-07:00', // PDT
|
||||
endDate: '2025-04-27T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'City View at Metreon',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description: 'We connected with the security community at BSides SF 2025 during RSA week.',
|
||||
fullDescription:
|
||||
'Promptfoo joined the BSides SF 2025 community event during RSA week. We participated in security discussions and connected with researchers working on AI security challenges.',
|
||||
cardImage: '/img/events/bsides-sf-2025.jpg',
|
||||
heroImage: '/img/events/bsides-sf-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Community',
|
||||
description: 'Connected with security researchers',
|
||||
},
|
||||
{
|
||||
icon: '💬',
|
||||
title: 'Discussions',
|
||||
description: 'AI security conversations',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/bsides-sf-2025',
|
||||
},
|
||||
{
|
||||
id: 'bsides-seattle-2025',
|
||||
slug: 'bsides-seattle-2025',
|
||||
name: 'Promptfoo at BSides Seattle 2025',
|
||||
shortName: 'BSides Seattle 2025',
|
||||
status: 'past',
|
||||
type: 'conference',
|
||||
startDate: '2025-04-18T09:00:00-07:00', // PDT
|
||||
endDate: '2025-04-19T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Building 92 (Microsoft Visitor Center)',
|
||||
city: 'Redmond',
|
||||
state: 'WA',
|
||||
country: 'USA',
|
||||
},
|
||||
description: 'We engaged with the Pacific Northwest security community at BSides Seattle 2025.',
|
||||
fullDescription:
|
||||
'Promptfoo participated in BSides Seattle 2025 at Building 92, engaging with the Pacific Northwest security community and discussing the latest in AI security threats and mitigations.',
|
||||
cardImage: '/img/events/bsides-seattle-2025.jpg',
|
||||
heroImage: '/img/events/bsides-seattle-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🌲',
|
||||
title: 'PNW Community',
|
||||
description: 'Connected with Seattle security pros',
|
||||
},
|
||||
{
|
||||
icon: '🔐',
|
||||
title: 'AI Security',
|
||||
description: 'Shared LLM security insights',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/bsides-seattle-2025',
|
||||
},
|
||||
{
|
||||
id: 'telecom-talks-2025',
|
||||
slug: 'telecom-talks-2025',
|
||||
name: 'Promptfoo at Telecom Talks 2025',
|
||||
shortName: 'Telecom Talks 2025',
|
||||
status: 'past',
|
||||
type: 'conference',
|
||||
startDate: '2025-04-09T09:00:00-07:00', // PDT
|
||||
endDate: '2025-04-09T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'SRI International',
|
||||
city: 'Menlo Park',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Ian Webster joined Swisscom Outpost on stage to discuss AI security in telecommunications.',
|
||||
fullDescription:
|
||||
'Promptfoo CEO Ian Webster joined Swisscom Outpost on stage at Telecom Talks 2025 to discuss the unique challenges of securing AI systems in telecommunications infrastructure.',
|
||||
cardImage: '/img/events/telecom-talks-2025.jpg',
|
||||
heroImage: '/img/events/telecom-talks-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '📡',
|
||||
title: 'Joint Session',
|
||||
description: 'On stage with Swisscom Outpost',
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
title: 'Telecom Focus',
|
||||
description: 'Carrier-grade AI security',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/telecom-talks-2025',
|
||||
},
|
||||
{
|
||||
id: 'sector-2025',
|
||||
slug: 'sector-2025',
|
||||
name: 'Promptfoo at SecTor 2025',
|
||||
shortName: 'SecTor 2025',
|
||||
status: getEventStatus('2025-10-02T18:00:00-04:00'),
|
||||
type: 'conference',
|
||||
startDate: '2025-09-30T09:00:00-04:00', // EDT
|
||||
endDate: '2025-10-02T18:00:00-04:00', // EDT
|
||||
location: {
|
||||
venue: 'Metro Toronto Convention Centre',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'Canada',
|
||||
},
|
||||
description:
|
||||
"Canada's largest IT security conference. Arsenal demos and enterprise AI security discussions.",
|
||||
fullDescription:
|
||||
"Promptfoo was selected for the SecTor Arsenal, showcasing open-source AI security tools to Canada's enterprise security community at Canada's largest IT security conference.",
|
||||
cardImage: '/img/events/sector-2025.jpg',
|
||||
heroImage: '/img/events/sector-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🍁',
|
||||
title: 'Arsenal Listing',
|
||||
description: 'Selected for SecTor Arsenal',
|
||||
},
|
||||
{
|
||||
icon: '🇨🇦',
|
||||
title: 'Canadian Enterprise',
|
||||
description: 'Major banks and government',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/sector-2025',
|
||||
},
|
||||
{
|
||||
id: 'ai-security-summit-2025',
|
||||
slug: 'ai-security-summit-2025',
|
||||
name: 'Promptfoo at AI Security Summit 2025',
|
||||
shortName: 'AI Security Summit 2025',
|
||||
status: getEventStatus('2025-10-23T18:00:00-07:00'),
|
||||
type: 'conference',
|
||||
startDate: '2025-10-22T09:00:00-07:00', // PDT
|
||||
endDate: '2025-10-23T18:00:00-07:00', // PDT
|
||||
location: {
|
||||
venue: 'Westin St. Francis',
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
country: 'USA',
|
||||
},
|
||||
description:
|
||||
'Two days of AI security research, expert panels, and cutting-edge demonstrations.',
|
||||
fullDescription:
|
||||
'Ian Webster joined industry leaders as a panel speaker at AI Security Summit 2025 to discuss the evolving landscape of LLM vulnerabilities and practical defense strategies.',
|
||||
cardImage: '/img/events/ai-security-summit-2025.jpg',
|
||||
heroImage: '/img/events/ai-security-summit-2025.jpg',
|
||||
highlights: [
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'Panel Speaker',
|
||||
description: 'Ian Webster on expert panel',
|
||||
},
|
||||
{
|
||||
icon: '🔬',
|
||||
title: 'Research',
|
||||
description: 'Latest AI security findings',
|
||||
},
|
||||
],
|
||||
customPageUrl: '/events/ai-security-summit-2025',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper functions
|
||||
export function getUpcomingEvents(): Event[] {
|
||||
return events
|
||||
.filter((event) => event.status === 'upcoming')
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
}
|
||||
|
||||
export function getPastEvents(): Event[] {
|
||||
return events
|
||||
.filter((event) => event.status === 'past')
|
||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
}
|
||||
|
||||
export function getEventBySlug(slug: string): Event | undefined {
|
||||
return events.find((event) => event.slug === slug);
|
||||
}
|
||||
|
||||
export function getEventsByYear(year: number): Event[] {
|
||||
return events.filter((event) => new Date(event.startDate).getFullYear() === year);
|
||||
}
|
||||
|
||||
export function getEventsByType(type: EventType): Event[] {
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
export function getFeaturedEvent(): Event | undefined {
|
||||
const upcoming = getUpcomingEvents();
|
||||
return upcoming.length > 0 ? upcoming[0] : undefined;
|
||||
}
|
||||
|
||||
export function formatEventDate(startDate: string, endDate: string): string {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const startMonth = start.toLocaleDateString('en-US', { month: 'short' });
|
||||
const endMonth = end.toLocaleDateString('en-US', { month: 'short' });
|
||||
const startDay = start.getDate();
|
||||
const endDay = end.getDate();
|
||||
const year = start.getFullYear();
|
||||
|
||||
if (startDate === endDate) {
|
||||
return `${startMonth} ${startDay}, ${year}`;
|
||||
}
|
||||
|
||||
if (startMonth === endMonth) {
|
||||
return `${startMonth} ${startDay}-${endDay}, ${year}`;
|
||||
}
|
||||
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`;
|
||||
}
|
||||
|
||||
export function formatEventLocation(location: EventLocation): string {
|
||||
return `${location.city}, ${location.state}`;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the cookie consent banner (site/static/js/consent.js).
|
||||
* We load and evaluate the IIFE in a jsdom environment, then verify
|
||||
* DOM state, cookie behavior, and script injection.
|
||||
*/
|
||||
|
||||
const CONSENT_JS = fs.readFileSync(path.resolve(__dirname, '../../static/js/consent.js'), 'utf-8');
|
||||
|
||||
function setCookie(name: string, value: string) {
|
||||
document.cookie = `${name}=${value};path=/`;
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
|
||||
return m ? m[2] : null;
|
||||
}
|
||||
|
||||
function clearCookies() {
|
||||
document.cookie.split(';').forEach((c) => {
|
||||
const name = c.split('=')[0].trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runConsent() {
|
||||
// eslint-disable-next-line no-eval
|
||||
const fn = new Function(CONSENT_JS);
|
||||
fn();
|
||||
}
|
||||
|
||||
describe('consent.js', () => {
|
||||
beforeEach(() => {
|
||||
clearCookies();
|
||||
document.body.innerHTML = '';
|
||||
document.head.querySelectorAll('#cc-styles').forEach((el) => el.remove());
|
||||
document.querySelectorAll('script[src]').forEach((el) => el.remove());
|
||||
(window as any).__pf_scripts_loaded = false;
|
||||
(window as any).__pf_manage_cookies = undefined;
|
||||
// Stub location.hash
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
hash: '',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
href: 'http://localhost/',
|
||||
reload: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('EU visitor without prior consent', () => {
|
||||
it('loads scripts when no pf_country cookie (defaults to non-EU)', () => {
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('shows banner for EU country code', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows banner for UK', () => {
|
||||
setCookie('pf_country', 'GB');
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows banner for Switzerland', () => {
|
||||
setCookie('pf_country', 'CH');
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-EU visitor', () => {
|
||||
it('does not show banner for US', () => {
|
||||
setCookie('pf_country', 'US');
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show banner for Japan', () => {
|
||||
setCookie('pf_country', 'JP');
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('loads scripts immediately', () => {
|
||||
setCookie('pf_country', 'US');
|
||||
runConsent();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(true);
|
||||
const gtagScript = document.querySelector('script[src*="googletagmanager"]');
|
||||
expect(gtagScript).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not load scripts when #manage-cookies hash is present', () => {
|
||||
setCookie('pf_country', 'US');
|
||||
(window as any).location.hash = '#manage-cookies';
|
||||
runConsent();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(false);
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accept flow', () => {
|
||||
it('sets pf_consent=1 and loads scripts on accept', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
runConsent();
|
||||
|
||||
const acceptBtn = document.getElementById('cc-accept')!;
|
||||
acceptBtn.click();
|
||||
|
||||
expect(getCookie('pf_consent')).toBe('1');
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decline flow', () => {
|
||||
it('sets pf_consent=0 and does not load scripts on decline', () => {
|
||||
setCookie('pf_country', 'FR');
|
||||
runConsent();
|
||||
|
||||
const declineBtn = document.getElementById('cc-decline')!;
|
||||
declineBtn.click();
|
||||
|
||||
expect(getCookie('pf_consent')).toBe('0');
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returning visitor with prior consent', () => {
|
||||
it('loads scripts immediately when pf_consent=1', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
setCookie('pf_consent', '1');
|
||||
runConsent();
|
||||
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when pf_consent=0', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
setCookie('pf_consent', '0');
|
||||
runConsent();
|
||||
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdraw consent', () => {
|
||||
it('exposes __pf_manage_cookies global', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
runConsent();
|
||||
expect(typeof (window as any).__pf_manage_cookies).toBe('function');
|
||||
});
|
||||
|
||||
it('reopens banner when __pf_manage_cookies is called', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
setCookie('pf_consent', '1');
|
||||
runConsent();
|
||||
|
||||
expect(document.getElementById('cc-banner')).toBeNull();
|
||||
|
||||
(window as any).__pf_manage_cookies();
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reloads page when declining after scripts were loaded', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
runConsent();
|
||||
|
||||
// Accept first
|
||||
document.getElementById('cc-accept')!.click();
|
||||
expect((window as any).__pf_scripts_loaded).toBe(true);
|
||||
|
||||
// Reopen and decline
|
||||
(window as any).__pf_manage_cookies();
|
||||
document.getElementById('cc-decline')!.click();
|
||||
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reload when declining on first visit (no scripts loaded)', () => {
|
||||
setCookie('pf_country', 'DE');
|
||||
runConsent();
|
||||
|
||||
document.getElementById('cc-decline')!.click();
|
||||
expect(window.location.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('script loading', () => {
|
||||
it('only loads scripts once even if loadScripts called multiple times', () => {
|
||||
setCookie('pf_country', 'US');
|
||||
runConsent();
|
||||
|
||||
const scriptCount = document.querySelectorAll('script[src*="scripts.js"]').length;
|
||||
expect(scriptCount).toBe(1);
|
||||
});
|
||||
|
||||
it('injects both gtag.js and scripts.js', () => {
|
||||
setCookie('pf_country', 'US');
|
||||
runConsent();
|
||||
|
||||
expect(document.querySelector('script[src*="googletagmanager"]')).not.toBeNull();
|
||||
expect(document.querySelector('script[src*="scripts.js"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EU country coverage', () => {
|
||||
const euCountries = [
|
||||
'AT',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'DE',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO', // EEA
|
||||
'GB', // UK
|
||||
'CH', // Switzerland
|
||||
];
|
||||
|
||||
it.each(euCountries)('shows banner for %s', (country) => {
|
||||
setCookie('pf_country', country);
|
||||
runConsent();
|
||||
expect(document.getElementById('cc-banner')).not.toBeNull();
|
||||
// Cleanup for next iteration
|
||||
document.getElementById('cc-banner')?.remove();
|
||||
clearCookies();
|
||||
(window as any).__pf_scripts_loaded = false;
|
||||
(window as any).__pf_manage_cookies = undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { onRequest } from '../../functions/_middleware';
|
||||
|
||||
function makeContext(opts: { contentType?: string; cfCountry?: string; cookies?: string }) {
|
||||
const responseHeaders = new Headers();
|
||||
if (opts.contentType) {
|
||||
responseHeaders.set('Content-Type', opts.contentType);
|
||||
}
|
||||
const response = new Response('', { headers: responseHeaders });
|
||||
|
||||
const requestHeaders = new Headers();
|
||||
if (opts.cfCountry) {
|
||||
requestHeaders.set('CF-IPCountry', opts.cfCountry);
|
||||
}
|
||||
if (opts.cookies) {
|
||||
requestHeaders.set('Cookie', opts.cookies);
|
||||
}
|
||||
|
||||
return {
|
||||
request: new Request('https://example.com/', { headers: requestHeaders }),
|
||||
next: vi.fn().mockResolvedValue(response),
|
||||
};
|
||||
}
|
||||
|
||||
describe('_middleware', () => {
|
||||
it('sets pf_country cookie on HTML responses', async () => {
|
||||
const ctx = makeContext({ contentType: 'text/html; charset=utf-8', cfCountry: 'DE' });
|
||||
const res = await onRequest(ctx);
|
||||
const setCookie = res.headers.get('Set-Cookie');
|
||||
expect(setCookie).toContain('pf_country=DE');
|
||||
expect(setCookie).toContain('Secure');
|
||||
expect(setCookie).toContain('SameSite=Lax');
|
||||
expect(setCookie).toContain('Max-Age=86400');
|
||||
});
|
||||
|
||||
it('does not set cookie on non-HTML responses', async () => {
|
||||
const ctx = makeContext({ contentType: 'application/javascript', cfCountry: 'DE' });
|
||||
const res = await onRequest(ctx);
|
||||
expect(res.headers.get('Set-Cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not set cookie on CSS responses', async () => {
|
||||
const ctx = makeContext({ contentType: 'text/css', cfCountry: 'DE' });
|
||||
const res = await onRequest(ctx);
|
||||
expect(res.headers.get('Set-Cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not set cookie on image responses', async () => {
|
||||
const ctx = makeContext({ contentType: 'image/png', cfCountry: 'US' });
|
||||
const res = await onRequest(ctx);
|
||||
expect(res.headers.get('Set-Cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not set cookie when CF-IPCountry header is missing', async () => {
|
||||
const ctx = makeContext({ contentType: 'text/html' });
|
||||
const res = await onRequest(ctx);
|
||||
expect(res.headers.get('Set-Cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('always refreshes country cookie to handle travel/VPN', async () => {
|
||||
const ctx = makeContext({
|
||||
contentType: 'text/html',
|
||||
cfCountry: 'FR',
|
||||
cookies: 'pf_country=US',
|
||||
});
|
||||
const res = await onRequest(ctx);
|
||||
const setCookie = res.headers.get('Set-Cookie');
|
||||
expect(setCookie).toContain('pf_country=FR');
|
||||
});
|
||||
|
||||
it('passes through response from next()', async () => {
|
||||
const ctx = makeContext({ contentType: 'text/html', cfCountry: 'GB' });
|
||||
const res = await onRequest(ctx);
|
||||
expect(ctx.next).toHaveBeenCalledOnce();
|
||||
expect(res).toBeInstanceOf(Response);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
export function useForcedTheme(theme: ThemeMode) {
|
||||
const previousThemeRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
previousThemeRef.current = document.documentElement.getAttribute('data-theme');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Re-enforce if something (e.g. system preference change) overrides the theme
|
||||
const observer = new MutationObserver(() => {
|
||||
if (document.documentElement.getAttribute('data-theme') !== theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (previousThemeRef.current) {
|
||||
document.documentElement.setAttribute('data-theme', previousThemeRef.current);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
};
|
||||
}, [theme]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useLocation } from '@docusaurus/router';
|
||||
|
||||
export function useIsDocsPage(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return pathname.startsWith('/docs/') || pathname === '/docs';
|
||||
}
|
||||
|
||||
export function useIsEventDetailPage(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
// Match /events/<slug> but not /events/ or /events (index page)
|
||||
return /^\/events\/[^/]+/.test(pathname);
|
||||
}
|
||||
|
||||
export function useIsStorePage(): boolean {
|
||||
const { pathname } = useLocation();
|
||||
return pathname === '/store' || pathname === '/store/';
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useColorMode } from '@docusaurus/theme-common';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import LinkedInIcon from '@mui/icons-material/LinkedIn';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../constants';
|
||||
|
||||
const AboutPageContent = () => {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const theme = React.useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: colorMode === 'dark' ? 'dark' : 'light',
|
||||
},
|
||||
}),
|
||||
[colorMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h2"
|
||||
align="center"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Securing the Future of AI
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h2"
|
||||
align="center"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Promptfoo helps developers and enterprises build secure, reliable AI applications.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={4}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h3"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
About Us
|
||||
</Typography>
|
||||
<Typography variant="body1" component="p" sx={{ mb: 2 }}>
|
||||
We are security and engineering practitioners who have scaled generative AI products
|
||||
to hundreds of millions of users. We're building the tools that we wished we had
|
||||
when we were on the front lines.
|
||||
</Typography>
|
||||
<Typography variant="body1" component="p" sx={{ mb: 2 }}>
|
||||
Based in San Francisco, California, Promptfoo is now part of OpenAI. Promptfoo
|
||||
remains open source, and we continue to build tools for secure, reliable AI
|
||||
applications.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/img/logo-panda.svg"
|
||||
alt="Promptfoo Logo"
|
||||
style={{ maxWidth: '100%', maxHeight: '150px', height: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 8 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h3"
|
||||
align="center"
|
||||
sx={{
|
||||
mb: 8,
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
Our founders
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
spacing={4}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
name: 'Ian Webster',
|
||||
title: 'CEO & Co-founder',
|
||||
image: '/img/team/ian.jpeg',
|
||||
bio: 'Ian previously led LLM engineering and developer platform teams at Discord, scaling AI products to 200M users while maintaining rigorous safety, security, and policy standards.',
|
||||
linkedin: 'http://linkedin.com/in/ianww',
|
||||
},
|
||||
{
|
||||
name: "Michael D'Angelo",
|
||||
title: 'CTO & Co-founder',
|
||||
image: '/img/team/michael.jpeg',
|
||||
bio: 'Michael brings extensive experience in AI and engineering leadership. As the former VP of Engineering and Head of AI at Smile Identity, he has a track record of scaling ML solutions to serve over 100 million people across hundreds of enterprises.',
|
||||
linkedin: 'https://www.linkedin.com/in/michaelldangelo/',
|
||||
},
|
||||
].map((leader) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={leader.name}>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
alt={leader.name}
|
||||
src={leader.image}
|
||||
sx={{ width: 150, height: 150, margin: '0 auto 1rem' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h4"
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
{leader.name}
|
||||
</Typography>
|
||||
{leader.linkedin && (
|
||||
<IconButton
|
||||
component="a"
|
||||
href={leader.linkedin}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="small"
|
||||
aria-label={`${leader.name}'s LinkedIn profile`}
|
||||
sx={{ padding: 0 }}
|
||||
>
|
||||
<LinkedInIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{leader.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" dangerouslySetInnerHTML={{ __html: leader.bio }} />
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 8 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h3"
|
||||
align="center"
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
Early Supporters
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
component="p"
|
||||
align="center"
|
||||
sx={{
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
We're grateful to the investors and operators who backed Promptfoo early and helped us
|
||||
build open-source, application-focused AI security.
|
||||
</Typography>
|
||||
<Grid
|
||||
container
|
||||
spacing={4}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
name: 'Ganesh Bell',
|
||||
image: '/img/team/ganesh.jpeg',
|
||||
description: 'Managing Director, Insight Partners',
|
||||
},
|
||||
{
|
||||
name: 'Zane Lackey',
|
||||
image: '/img/team/zane.jpeg',
|
||||
description: 'General Partner, Andreessen Horowitz\nFounder, Signal Sciences',
|
||||
},
|
||||
{
|
||||
name: 'Joel de la Garza',
|
||||
image: '/img/team/joel.jpeg',
|
||||
description: 'Investment Partner, Andreessen Horowitz\nCISO, Box',
|
||||
},
|
||||
{
|
||||
name: 'Tobi Lutke',
|
||||
image: '/img/team/tobi.jpeg',
|
||||
description: 'CEO, Shopify',
|
||||
},
|
||||
{
|
||||
name: 'Stanislav Vishnevskiy',
|
||||
image: '/img/team/stan.jpeg',
|
||||
description: 'CTO, Discord',
|
||||
},
|
||||
{
|
||||
name: 'Frederic Kerrest',
|
||||
image: '/img/team/frederic.jpeg',
|
||||
description: 'Vice-Chairman & Co-Founder, Okta',
|
||||
},
|
||||
{
|
||||
name: 'Adam Ely',
|
||||
image: '/img/team/adam.jpeg',
|
||||
description: 'EVP, Head of Digital Products, Fidelity\nCISO, Fidelity',
|
||||
},
|
||||
].map((investor) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={investor.name}>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
alt={investor.name}
|
||||
src={investor.image}
|
||||
sx={{ width: 120, height: 120, margin: '0 auto 1rem' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
{investor.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
>
|
||||
{investor.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 8 }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h3"
|
||||
align="center"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
An Incredible Open Source Community
|
||||
</Typography>
|
||||
<Typography variant="body1" component="p" align="center" sx={{ mb: 2 }}>
|
||||
Promptfoo is proud to be supported by a vibrant community of over{' '}
|
||||
{SITE_CONSTANTS.CONTRIBUTOR_COUNT} open source contributors.
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
<a href="https://github.com/promptfoo/promptfoo/graphs/contributors">
|
||||
<img
|
||||
src="https://contrib.rocks/image?repo=promptfoo/promptfoo"
|
||||
alt="Promptfoo Contributors"
|
||||
/>
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mb: 8,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h3"
|
||||
gutterBottom
|
||||
sx={{
|
||||
fontWeight: 'medium',
|
||||
}}
|
||||
>
|
||||
Ready to Secure Your AI Applications?
|
||||
</Typography>
|
||||
<Typography variant="body1" component="p" sx={{ mb: 2 }}>
|
||||
Join leading enterprises who trust Promptfoo to fortify their AI applications.
|
||||
</Typography>
|
||||
<Link className="button button--primary button--lg" to="/contact/">
|
||||
Get in Touch
|
||||
</Link>
|
||||
</Box>
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutPage = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const siteUrl = siteConfig.url;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="About Promptfoo | AI Security Experts"
|
||||
description="Learn about Promptfoo's mission to secure AI applications and our team of industry veterans."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="About Promptfoo - Securing the Future of AI" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Learn about Promptfoo's mission to secure AI applications and our team of industry veterans."
|
||||
/>
|
||||
<meta property="og:image" content={`${siteUrl}/img/og/about-og.png`} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={`${siteUrl}/about`} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="About Promptfoo - Securing the Future of AI" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Learn about Promptfoo's mission to secure AI applications and our team of industry veterans."
|
||||
/>
|
||||
<meta name="twitter:image" content={`${siteUrl}/img/og/about-og.png`} />
|
||||
<link rel="canonical" href={`${siteUrl}/about`} />
|
||||
</Head>
|
||||
<AboutPageContent />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
@@ -0,0 +1,371 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import IntegrationInstructionsIcon from '@mui/icons-material/IntegrationInstructions';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import Layout from '@theme/Layout';
|
||||
import clsx from 'clsx';
|
||||
import LogoContainer from '../components/LogoContainer';
|
||||
import styles from './landing-page.module.css';
|
||||
|
||||
function HeroSection() {
|
||||
return (
|
||||
<section className={styles.heroSection}>
|
||||
<div className="container">
|
||||
<img
|
||||
src="/img/code-scanning-hero.svg"
|
||||
alt="LLM Security Code Scanning"
|
||||
className={styles.heroImage}
|
||||
/>
|
||||
<div className={styles.logoSection}>
|
||||
Promptfoo is trusted by teams at...
|
||||
<LogoContainer className={styles.heroLogos} noBackground noBorder />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeScanningHeader() {
|
||||
return (
|
||||
<header className={clsx('hero', styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<div className={styles.heroContent}>
|
||||
<h1 className={styles.heroTitle}>Secure AI code in development</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Find LLM vulnerabilities in your IDE and CI/CD - before they reach production
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<Link
|
||||
className={clsx('button button--primary button--lg', styles.buttonPrimary)}
|
||||
to="/contact/"
|
||||
>
|
||||
Request Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HeroSection />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationOptionsSection() {
|
||||
return (
|
||||
<section className={styles.actionOrientedSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>SHIFT-LEFT SECURITY</div>
|
||||
<h2 className={styles.sectionTitle}>Coverage across your development workflow</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Catch vulnerabilities at the earliest possible moment—from the first line of code to
|
||||
deployment
|
||||
</p>
|
||||
|
||||
<div className={styles.securityFlowContainer}>
|
||||
<div className={styles.securityFlowStep}>
|
||||
<div className={styles.approachIcon}>
|
||||
<CodeIcon fontSize="large" />
|
||||
</div>
|
||||
<h3>IDE Integration</h3>
|
||||
<p>Real-time scanning as developers write code</p>
|
||||
<ul>
|
||||
<li>Inline diagnostics and severity indicators</li>
|
||||
<li>One-click quick fixes</li>
|
||||
<li>AI-assisted remediation prompts</li>
|
||||
<li>Scan on save or on demand</li>
|
||||
</ul>
|
||||
<div className={styles.capabilityDetails}>
|
||||
<Link to="/contact/" className="button button--primary button--sm">
|
||||
Request Access
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.securityFlowArrow}>→</div>
|
||||
|
||||
<div className={styles.securityFlowStep}>
|
||||
<div className={styles.approachIcon}>
|
||||
<GitHubIcon fontSize="large" />
|
||||
</div>
|
||||
<h3>Pull Request Review</h3>
|
||||
<p>Automated security review before code merges</p>
|
||||
<ul>
|
||||
<li>Findings posted as PR comments</li>
|
||||
<li>Suggested fixes inline</li>
|
||||
<li>Severity-based blocking</li>
|
||||
<li>Easy GitHub integration</li>
|
||||
</ul>
|
||||
<div className={styles.capabilityDetails}>
|
||||
<Link
|
||||
to="/code-scanning/github-action/"
|
||||
className="button button--primary button--sm"
|
||||
target="_blank"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.securityFlowArrow}>→</div>
|
||||
|
||||
<div className={styles.securityFlowStep}>
|
||||
<div className={styles.approachIcon}>
|
||||
<TerminalIcon fontSize="large" />
|
||||
</div>
|
||||
<h3>CI/CD Pipeline</h3>
|
||||
<p>Integrate into any build and deployment process</p>
|
||||
<ul>
|
||||
<li>Jenkins, GitLab, CircleCI, and more</li>
|
||||
<li>JSON output for automation</li>
|
||||
<li>Configurable severity thresholds</li>
|
||||
<li>Fail builds on critical findings</li>
|
||||
</ul>
|
||||
<div className={styles.capabilityDetails}>
|
||||
<Link to="/docs/code-scanning/cli/" className="button button--secondary button--sm">
|
||||
CLI Documentation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SeeItInActionSection() {
|
||||
return (
|
||||
<section className={styles.actionOrientedSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>SEE IT IN ACTION</div>
|
||||
<h2 className={styles.sectionTitle}>Security feedback where developers work</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
AI agents trace data flows across your codebase to find vulnerabilities that span multiple
|
||||
files—then surface findings with actionable remediation
|
||||
</p>
|
||||
|
||||
<div className={styles.showcaseRow}>
|
||||
<div className={styles.showcaseText}>
|
||||
<h3>Real-time IDE scanning</h3>
|
||||
<p>
|
||||
Inline diagnostics, severity indicators, and one-click fixes as you write code. Catch
|
||||
vulnerabilities the moment they're introduced—before they ever leave your editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.showcaseImage}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/img/docs/code-scanning/vscode-extension.png"
|
||||
alt="VS Code extension showing inline security diagnostics"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(styles.showcaseRow, styles.showcaseRowReverse)}>
|
||||
<div className={styles.showcaseText}>
|
||||
<h3>Automated PR review</h3>
|
||||
<p>
|
||||
Security findings posted as PR comments with suggested fixes before code merges. Block
|
||||
risky changes automatically based on severity thresholds.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.showcaseImage}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/img/docs/code-scanning/github.png"
|
||||
alt="GitHub PR with security findings posted as review comments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function VulnerabilityTypesSection() {
|
||||
const vulnerabilities = [
|
||||
{
|
||||
severity: 'critical',
|
||||
name: 'Prompt Injection',
|
||||
description: 'Untrusted input reaches LLM prompts without proper sanitization or boundaries.',
|
||||
},
|
||||
{
|
||||
severity: 'critical',
|
||||
name: 'Data Exfiltration',
|
||||
description: 'Indirect prompt injection vectors that could extract data through agent tools.',
|
||||
},
|
||||
{
|
||||
severity: 'high',
|
||||
name: 'PII Exposure',
|
||||
description:
|
||||
'Code that may leak sensitive user data to LLMs or log confidential information.',
|
||||
},
|
||||
{
|
||||
severity: 'high',
|
||||
name: 'Improper Output Handling',
|
||||
description: 'LLM outputs used in dangerous contexts like SQL queries or shell commands.',
|
||||
},
|
||||
{
|
||||
severity: 'medium',
|
||||
name: 'Excessive Agency',
|
||||
description: 'LLMs with overly broad tool access or missing approval gates for actions.',
|
||||
},
|
||||
{
|
||||
severity: 'medium',
|
||||
name: 'Jailbreak Risks',
|
||||
description: 'Weak system prompts and guardrail bypasses that could allow harmful outputs.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.vulnerabilitySection}>
|
||||
<div className="container">
|
||||
<div className={styles.vulnEyebrow}>LLM-SPECIFIC DETECTION</div>
|
||||
<h2 className={styles.vulnTitle}>Find what other scanners miss</h2>
|
||||
<p className={styles.vulnSubtitle}>
|
||||
Purpose-built for AI security risks that general SAST tools overlook
|
||||
</p>
|
||||
<div className={styles.vulnGrid}>
|
||||
{vulnerabilities.map((vuln) => (
|
||||
<div key={vuln.name} className={styles.vulnCard}>
|
||||
<div className={`${styles.vulnSeverity} ${styles[vuln.severity]}`}>
|
||||
{vuln.severity}
|
||||
</div>
|
||||
<h3 className={styles.vulnName}>{vuln.name}</h3>
|
||||
<p className={styles.vulnDescription}>{vuln.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProofBannerSection() {
|
||||
return (
|
||||
<section className={styles.proofBanner}>
|
||||
<div className={clsx('container', styles.proofBannerContainer)}>
|
||||
<ArticleIcon className={styles.proofBannerIcon} />
|
||||
<div className={styles.proofBannerContent}>
|
||||
<h3 className={styles.proofBannerTitle}>See it in action</h3>
|
||||
<p className={styles.proofBannerText}>
|
||||
We tested the scanner against real CVEs in LangChain, Vanna.AI, and LlamaIndex. Read the
|
||||
technical deep dive to see how it catches vulnerabilities that other tools miss.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={clsx('button button--secondary', styles.proofBannerButton)}
|
||||
to="/blog/building-a-security-scanner-for-llm-apps"
|
||||
>
|
||||
Read the technical breakdown
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsSection() {
|
||||
return (
|
||||
<section className={styles.benefitsSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>WHY CODE SCANNING</div>
|
||||
<h2 className={styles.sectionTitle}>Security that scales with AI adoption</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Find vulnerabilities where fixes are 10x faster and cheaper—without slowing down
|
||||
development
|
||||
</p>
|
||||
<div className={styles.benefitsList}>
|
||||
<div className={styles.benefitItem}>
|
||||
<SearchIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Deep data flow analysis</h3>
|
||||
<p>
|
||||
AI agents trace how user inputs flow through your code to LLM prompts, catching
|
||||
subtle vulnerabilities that span multiple files and modules—not just surface-level
|
||||
pattern matching.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<SecurityIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>LLM-specific detection</h3>
|
||||
<p>
|
||||
Purpose-built for AI security risks that general SAST tools miss. High signal, low
|
||||
noise—no alert fatigue from irrelevant findings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<IntegrationInstructionsIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Embedded in developer workflow</h3>
|
||||
<p>
|
||||
Security feedback in the IDE and PR comments with actionable remediation. Developers
|
||||
fix issues without context switching or separate dashboards.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<CodeIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Complete development coverage</h3>
|
||||
<p>
|
||||
From the first line of code to deployment. IDE catches issues immediately, PR review
|
||||
prevents merges, CI/CD ensures nothing slips through.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CallToActionSection() {
|
||||
return (
|
||||
<section className={styles.finalCTA}>
|
||||
<div className="container">
|
||||
<h2 className={styles.finalCTATitle}>Secure AI development from day one</h2>
|
||||
<p className={styles.finalCTASubtitle}>
|
||||
Get complete coverage across your development workflow—IDE, pull requests, and CI/CD.
|
||||
</p>
|
||||
<div className={styles.finalCTAButtons}>
|
||||
<Link className="button button--primary button--lg" to="/contact/">
|
||||
Request Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CodeScanning(): React.ReactElement {
|
||||
return (
|
||||
<Layout
|
||||
title="Code Scanning for LLM Security"
|
||||
description="AI-powered code scanning that finds LLM security vulnerabilities in pull requests. Detect prompt injection, PII exposure, and jailbreak risks before you merge."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/meta/code-scanning.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</Head>
|
||||
<div className={styles.pageContainer}>
|
||||
<CodeScanningHeader />
|
||||
<main className={styles.mainContent}>
|
||||
<IntegrationOptionsSection />
|
||||
<SeeItInActionSection />
|
||||
<VulnerabilityTypesSection />
|
||||
<ProofBannerSection />
|
||||
<BenefitsSection />
|
||||
<CallToActionSection />
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import BiotechIcon from '@mui/icons-material/Biotech';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
|
||||
import Layout from '@theme/Layout';
|
||||
import clsx from 'clsx';
|
||||
import LogoContainer from '../../components/LogoContainer';
|
||||
import styles from '../landing-page.module.css';
|
||||
|
||||
function HeroSection() {
|
||||
return (
|
||||
<section className={styles.heroSection}>
|
||||
<div className="container">
|
||||
<img
|
||||
src="/img/docs/code-scanning/github.png"
|
||||
alt="GitHub PR with security findings"
|
||||
className={clsx(styles.heroImage, styles.heroImageGitHubScanner)}
|
||||
/>
|
||||
<div className={styles.logoSection}>
|
||||
Promptfoo is trusted by teams at...
|
||||
<LogoContainer className={styles.heroLogos} noBackground noBorder />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubActionHeader() {
|
||||
return (
|
||||
<header className={clsx('hero', styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<div className={styles.heroContent}>
|
||||
<h1 className={styles.heroTitle}>Security scanning for LLM apps</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Find AI-based vulnerabilities in pull requests before you merge.
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<Link
|
||||
className={clsx('button button--primary button--lg', styles.buttonPrimary)}
|
||||
to="https://github.com/apps/promptfoo-scanner"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Install on GitHub
|
||||
</Link>
|
||||
<Link
|
||||
className={clsx('button button--secondary button--lg', styles.buttonSecondary)}
|
||||
to="/docs/code-scanning/github-action/"
|
||||
>
|
||||
View Docs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HeroSection />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function VulnerabilityTypesSection() {
|
||||
const vulnerabilities = [
|
||||
{
|
||||
severity: 'critical',
|
||||
name: 'Prompt Injection',
|
||||
description: 'Untrusted input reaches LLM prompts without proper sanitization or boundaries.',
|
||||
},
|
||||
{
|
||||
severity: 'critical',
|
||||
name: 'Data Exfiltration',
|
||||
description: 'Indirect prompt injection vectors that could extract data through agent tools.',
|
||||
},
|
||||
{
|
||||
severity: 'high',
|
||||
name: 'PII Exposure',
|
||||
description:
|
||||
'Code that may leak sensitive user data to LLMs or log confidential information.',
|
||||
},
|
||||
{
|
||||
severity: 'high',
|
||||
name: 'Improper Output Handling',
|
||||
description: 'LLM outputs used in dangerous contexts like SQL queries or shell commands.',
|
||||
},
|
||||
{
|
||||
severity: 'medium',
|
||||
name: 'Excessive Agency',
|
||||
description: 'LLMs with overly broad tool access or missing approval gates for actions.',
|
||||
},
|
||||
{
|
||||
severity: 'medium',
|
||||
name: 'Jailbreak Risks',
|
||||
description: 'Weak system prompts and guardrail bypasses that could allow harmful outputs.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={styles.vulnerabilitySection}>
|
||||
<div className="container">
|
||||
<div className={styles.vulnEyebrow}>LLM-specific vulnerabilities</div>
|
||||
<h2 className={styles.vulnTitle}>Catch issues that other review tools miss</h2>
|
||||
<p className={styles.vulnSubtitle}>
|
||||
Our scanner is laser-focused on the kinds of vulnerabilities that apps built on LLMs and
|
||||
agents are uniquely susceptible to.
|
||||
</p>
|
||||
<div className={styles.vulnGrid}>
|
||||
{vulnerabilities.map((vuln) => (
|
||||
<div key={vuln.name} className={styles.vulnCard}>
|
||||
<div className={`${styles.vulnSeverity} ${styles[vuln.severity]}`}>
|
||||
{vuln.severity}
|
||||
</div>
|
||||
<h3 className={styles.vulnName}>{vuln.name}</h3>
|
||||
<p className={styles.vulnDescription}>{vuln.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProofBannerSection() {
|
||||
return (
|
||||
<section className={styles.proofBanner}>
|
||||
<div className={clsx('container', styles.proofBannerContainer)}>
|
||||
<ArticleIcon className={styles.proofBannerIcon} />
|
||||
<div className={styles.proofBannerContent}>
|
||||
<h3 className={styles.proofBannerTitle}>See it in action</h3>
|
||||
<p className={styles.proofBannerText}>
|
||||
We tested the scanner against real CVEs in LangChain, Vanna.AI, and LlamaIndex. Read the
|
||||
technical deep dive to see how it catches vulnerabilities that other tools miss.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
className={clsx('button button--secondary', styles.proofBannerButton)}
|
||||
to="/blog/building-a-security-scanner-for-llm-apps"
|
||||
>
|
||||
Read the technical breakdown
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsSection() {
|
||||
return (
|
||||
<section className={styles.benefitsSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>Built for developers</div>
|
||||
<h2 className={styles.sectionTitle}>Real security that fits your workflow</h2>
|
||||
<p className={styles.sectionSubtitle}>Flag dangerous code without adding friction.</p>
|
||||
<div className={styles.benefitsList}>
|
||||
<div className={styles.benefitItem}>
|
||||
<BiotechIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Deep tracing</h3>
|
||||
<p>
|
||||
Beyond the PR itself, the scanner agentically traces LLM inputs, outputs, and
|
||||
capability changes deep into the larger repository to identify subtle yet critical
|
||||
issues that human reviewers can struggle to catch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<VolumeOffIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>No noise</h3>
|
||||
<p>
|
||||
Despite the comprehensive approach, it has a high bar for reporting, avoiding false
|
||||
positives and alert fatigue. Maintainers can configure severity levels and provide
|
||||
custom instructions to tailor sensitivity to their needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<AutoFixHighIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Fix suggestions</h3>
|
||||
<p>
|
||||
Every finding includes a suggested remediation, as well as a prompt that can be
|
||||
passed straight to an AI coding agent to further investigate and address the issue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CallToActionSection() {
|
||||
return (
|
||||
<section className={styles.finalCTA}>
|
||||
<div className="container">
|
||||
<h2 className={styles.finalCTATitle}>Start scanning PRs right now</h2>
|
||||
<p className={styles.finalCTASubtitle}>No account, credit card, or API keys required.</p>
|
||||
<div className={styles.finalCTAButtons}>
|
||||
<Link
|
||||
className={clsx('button button--primary button--lg', styles.buttonPrimary)}
|
||||
to="https://github.com/apps/promptfoo-scanner"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Install on GitHub
|
||||
</Link>
|
||||
<Link
|
||||
className={clsx('button button--secondary button--lg', styles.buttonSecondary)}
|
||||
to="/docs/code-scanning/github-action/"
|
||||
>
|
||||
Read the Docs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitHubAction(): React.ReactElement {
|
||||
return (
|
||||
<Layout
|
||||
title="GitHub Action for LLM Security Scanning"
|
||||
description="Automatically scan pull requests for LLM vulnerabilities. Find prompt injection, PII exposure, and AI security risks before you merge."
|
||||
>
|
||||
<Head>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/docs/code-scanning/github.png"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</Head>
|
||||
<div className={styles.pageContainer}>
|
||||
<GitHubActionHeader />
|
||||
<main className={styles.mainContent}>
|
||||
<VulnerabilityTypesSection />
|
||||
<ProofBannerSection />
|
||||
<BenefitsSection />
|
||||
<CallToActionSection />
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
.pageWrapper {
|
||||
padding: 4rem 0 5rem;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(229, 58, 58, 0.06), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(255, 122, 122, 0.05), transparent 28%);
|
||||
}
|
||||
|
||||
.heroSection {
|
||||
max-width: 760px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.heroChip {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-primary);
|
||||
background: rgba(229, 58, 58, 0.08);
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
max-width: 720px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mainLayout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.8fr);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contactCard,
|
||||
.sidebarCard {
|
||||
border: 1px solid var(--ifm-color-emphasis-200);
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.contactCard {
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.contactForm {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.submitRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.emailLink {
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebarColumn {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebarCard {
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.logoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.125rem 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.72;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.shopifyLogo {
|
||||
max-height: 32px;
|
||||
}
|
||||
|
||||
.anthropicLogo {
|
||||
max-height: 14px;
|
||||
}
|
||||
|
||||
.microsoftLogo {
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.discordLogo {
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
.doordashLogo {
|
||||
max-height: 16px;
|
||||
}
|
||||
|
||||
.carvanaLogo {
|
||||
width: 102px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .brandLogo {
|
||||
filter: grayscale(100%) invert(100%);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.testimonialList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.testimonialItem {
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.testimonialItem:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.testimonialItem:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.testimonialHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.testimonialSource {
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
}
|
||||
|
||||
.testimonialQuote {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.testimonialLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.8125rem !important;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.testimonialLink:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.testimonialLinkIcon {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
.openaiTestimonialLogo {
|
||||
height: 20px;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.anthropicTestimonialLogo {
|
||||
height: 10px;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.awsTestimonialLogo {
|
||||
height: 22px;
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .openaiTestimonialLogo,
|
||||
[data-theme='dark'] .anthropicTestimonialLogo,
|
||||
[data-theme='dark'] .awsTestimonialLogo {
|
||||
filter: grayscale(100%) invert(100%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.resourceLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.resourceLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.resourceLink:hover {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.resourceLinkIcon {
|
||||
font-size: 1rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.resourceLink:hover .resourceLinkIcon {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.pageWrapper {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.mainLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebarColumn {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageWrapper {
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.heroSection {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.contactCard,
|
||||
.sidebarCard {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.formGrid,
|
||||
.sidebarColumn {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.submitRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useColorMode } from '@docusaurus/theme-common';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Container from '@mui/material/Container';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Select from '@mui/material/Select';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './contact.module.css';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
href: 'https://vimeo.com/1023317525/be082a1029',
|
||||
logo: '/img/brands/openai-logo.svg',
|
||||
logoAlt: 'OpenAI',
|
||||
logoClassName: styles.openaiTestimonialLogo,
|
||||
quote: 'Promptfoo is really powerful. It is faster and more straightforward.',
|
||||
source: 'Build Hours',
|
||||
cta: 'Watch video',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/anthropics/courses/tree/master/prompt_evaluations',
|
||||
logo: '/img/brands/anthropic-logo.svg',
|
||||
logoAlt: 'Anthropic',
|
||||
logoClassName: styles.anthropicTestimonialLogo,
|
||||
quote: 'A streamlined solution that significantly reduces testing effort.',
|
||||
source: 'Courses',
|
||||
cta: 'See course',
|
||||
},
|
||||
{
|
||||
href: 'https://catalog.workshops.aws/promptfoo/',
|
||||
logo: '/img/brands/aws-logo.svg',
|
||||
logoAlt: 'AWS',
|
||||
logoClassName: styles.awsTestimonialLogo,
|
||||
quote: 'Promptfoo works particularly well with Amazon Bedrock.',
|
||||
source: 'Workshops',
|
||||
cta: 'View workshop',
|
||||
},
|
||||
];
|
||||
|
||||
function Contact(): React.ReactElement {
|
||||
const isDarkTheme = useColorMode().colorMode === 'dark';
|
||||
|
||||
const theme = React.useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: isDarkTheme ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: isDarkTheme ? '#ff7a7a' : '#e53a3a',
|
||||
dark: isDarkTheme ? '#e53a3a' : '#cb3434',
|
||||
contrastText: isDarkTheme ? '#10191c' : '#ffffff',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isDarkTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Box className={styles.pageWrapper}>
|
||||
<Container maxWidth="lg">
|
||||
<Box className={styles.heroSection}>
|
||||
<Chip label="Enterprise" className={styles.heroChip} size="small" />
|
||||
<Typography variant="h2" component="h1" className={styles.heroTitle}>
|
||||
Talk to our AI security team
|
||||
</Typography>
|
||||
<Typography variant="h6" className={styles.heroSubtitle}>
|
||||
We help security, platform, and ML teams evaluate risk, enforce policy, and ship
|
||||
reliable AI applications.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.mainLayout}>
|
||||
<Paper className={styles.contactCard} elevation={0}>
|
||||
<Box className={styles.cardHeader}>
|
||||
<Typography variant="h5" component="h2" sx={{ fontWeight: 600 }}>
|
||||
Request a demo
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Tell us about your environment and what you want to accomplish.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<form action="https://submit-form.com/ghriv7voL" className={styles.contactForm}>
|
||||
<Box className={styles.formGrid}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Full name"
|
||||
variant="outlined"
|
||||
required
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="email"
|
||||
name="email"
|
||||
label="Work email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
required
|
||||
margin="normal"
|
||||
helperText="Please use your company email address"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.formGrid}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="company"
|
||||
name="company"
|
||||
label="Company"
|
||||
variant="outlined"
|
||||
required
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="title"
|
||||
name="title"
|
||||
label="Job title"
|
||||
variant="outlined"
|
||||
margin="normal"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth margin="normal" variant="outlined" required>
|
||||
<InputLabel id="interested-in-label">I'm interested in</InputLabel>
|
||||
<Select
|
||||
labelId="interested-in-label"
|
||||
id="interested-in"
|
||||
name="interested-in"
|
||||
label="I'm interested in"
|
||||
>
|
||||
<MenuItem value="Enterprise Security">
|
||||
Enterprise Security & Red Teaming
|
||||
</MenuItem>
|
||||
<MenuItem value="AI Guardrails">AI Guardrails & Compliance</MenuItem>
|
||||
<MenuItem value="Model Evaluation">Model Evaluation & Testing</MenuItem>
|
||||
<MenuItem value="Custom Solution">Custom Enterprise Solution</MenuItem>
|
||||
<MenuItem value="Other">Other</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="message"
|
||||
name="message"
|
||||
label="How can we help?"
|
||||
multiline
|
||||
rows={5}
|
||||
variant="outlined"
|
||||
required
|
||||
margin="normal"
|
||||
placeholder="Share a few details about your application, timeline, and deployment requirements."
|
||||
/>
|
||||
|
||||
<Box className={styles.submitRow}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
endIcon={<ArrowForwardIcon />}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Contact sales
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Or email{' '}
|
||||
<MuiLink
|
||||
href="mailto:inquiries@promptfoo.dev"
|
||||
underline="hover"
|
||||
className={styles.emailLink}
|
||||
>
|
||||
inquiries@promptfoo.dev
|
||||
</MuiLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Box className={styles.sidebarColumn}>
|
||||
<Paper className={styles.sidebarCard} elevation={0}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, marginBottom: '1rem' }}>
|
||||
Trusted by leading teams
|
||||
</Typography>
|
||||
<Box className={styles.logoGrid}>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/shopify-logo.svg"
|
||||
alt="Shopify"
|
||||
className={`${styles.brandLogo} ${styles.shopifyLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/anthropic-logo.svg"
|
||||
alt="Anthropic"
|
||||
className={`${styles.brandLogo} ${styles.anthropicLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/microsoft-logo.svg"
|
||||
alt="Microsoft"
|
||||
className={`${styles.brandLogo} ${styles.microsoftLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/discord-logo-blue.svg"
|
||||
alt="Discord"
|
||||
className={`${styles.brandLogo} ${styles.discordLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/doordash-logo.svg"
|
||||
alt="DoorDash"
|
||||
className={`${styles.brandLogo} ${styles.doordashLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={styles.logoItem}>
|
||||
<img
|
||||
src="/img/brands/carvana-logo.svg"
|
||||
alt="Carvana"
|
||||
className={`${styles.brandLogo} ${styles.carvanaLogo}`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper className={styles.sidebarCard} elevation={0}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, marginBottom: '1rem' }}>
|
||||
What users say
|
||||
</Typography>
|
||||
<Box className={styles.testimonialList}>
|
||||
{testimonials.map((testimonial) => (
|
||||
<Box key={testimonial.source} className={styles.testimonialItem}>
|
||||
<Box className={styles.testimonialHeader}>
|
||||
<img
|
||||
src={testimonial.logo}
|
||||
alt={testimonial.logoAlt}
|
||||
className={testimonial.logoClassName}
|
||||
/>
|
||||
<Typography variant="body2" className={styles.testimonialSource}>
|
||||
{testimonial.source}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" className={styles.testimonialQuote}>
|
||||
"{testimonial.quote}"
|
||||
</Typography>
|
||||
<MuiLink
|
||||
href={testimonial.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.testimonialLink}
|
||||
underline="none"
|
||||
>
|
||||
{testimonial.cta}
|
||||
<ArrowForwardIcon className={styles.testimonialLinkIcon} />
|
||||
</MuiLink>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper className={styles.sidebarCard} elevation={0}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, marginBottom: '1rem' }}>
|
||||
Resources
|
||||
</Typography>
|
||||
<Box className={styles.resourceLinks}>
|
||||
<MuiLink
|
||||
href="https://github.com/promptfoo/promptfoo"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.resourceLink}
|
||||
underline="none"
|
||||
>
|
||||
GitHub
|
||||
<ArrowForwardIcon className={styles.resourceLinkIcon} />
|
||||
</MuiLink>
|
||||
<MuiLink
|
||||
href="https://discord.gg/promptfoo"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.resourceLink}
|
||||
underline="none"
|
||||
>
|
||||
Discord community
|
||||
<ArrowForwardIcon className={styles.resourceLinkIcon} />
|
||||
</MuiLink>
|
||||
<Link to="/docs/enterprise/" className={styles.resourceLink}>
|
||||
Enterprise documentation
|
||||
<ArrowForwardIcon className={styles.resourceLinkIcon} />
|
||||
</Link>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page(): React.ReactElement {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const siteUrl = siteConfig.url;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Contact Enterprise Sales"
|
||||
description="Contact Promptfoo about enterprise AI security solutions, red teaming, guardrails, and compliance."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Contact Promptfoo" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Contact Promptfoo about enterprise AI security solutions, red teaming, guardrails, and compliance."
|
||||
/>
|
||||
<meta property="og:image" content={`${siteUrl}/img/og/contact-og.png`} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={`${siteUrl}/contact`} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Contact Promptfoo" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Contact Promptfoo about enterprise AI security solutions, red teaming, guardrails, and compliance."
|
||||
/>
|
||||
<meta name="twitter:image" content={`${siteUrl}/img/og/contact-og.png`} />
|
||||
<link rel="canonical" href={`${siteUrl}/contact`} />
|
||||
</Head>
|
||||
<Contact />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import Layout from '@theme/Layout';
|
||||
|
||||
function getScalarTheme() {
|
||||
if (typeof document === 'undefined') {
|
||||
return 'alternate';
|
||||
}
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'alternate';
|
||||
}
|
||||
|
||||
export default function ApiReference() {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference';
|
||||
script.setAttribute('data-theme', getScalarTheme());
|
||||
script.addEventListener('load', () => {
|
||||
// Once Scalar is loaded from the CDN, we can reference the object
|
||||
(window as any).Scalar.createApiReference('#app', {
|
||||
url: 'https://api.promptfoo.app/static/openapi.json',
|
||||
});
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(script);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Hack to apply the theme to the Scalar app
|
||||
// The 'light' theme is broken, so we use the 'alternate' theme instead
|
||||
useEffect(() => {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
app.dataset.theme = getScalarTheme();
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
const observer = new MutationObserver(applyTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout title="API Reference | Promptfoo" description="API Reference">
|
||||
<main id="app"></main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/* AI Security Summit 2025 - Neural Network / AI Theme */
|
||||
/* Color Palette: Deep purple (#7C3AED), Electric blue (#3B82F6), Neural pink (#EC4899) */
|
||||
|
||||
.summitPage {
|
||||
background: #0a0a1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 10, 26, 0.4) 0%, rgba(10, 10, 26, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(10, 10, 26, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
color: #e9d5ff;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 50%, #ec4899 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(124, 58, 237, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #c4b5fd;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.speakerSection,
|
||||
.themesSection,
|
||||
.researchSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.speakerSection {
|
||||
background: rgba(124, 58, 237, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Speaker Card */
|
||||
.speakerCard {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.speakerInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.speakerBadge {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
padding: 0.375rem 1rem;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #ec4899 100%);
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.speakerName {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.speakerRole {
|
||||
font-size: 1rem;
|
||||
color: #7c3aed;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speakerBio {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.speakerTopics {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.topic {
|
||||
padding: 0.375rem 0.875rem;
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
color: #c4b5fd;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Themes Grid */
|
||||
.themesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.themeCard {
|
||||
background: rgba(15, 15, 46, 0.6);
|
||||
border: 1px solid rgba(124, 58, 237, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.themeCard:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.themeCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.themeCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Research Grid */
|
||||
.researchSection {
|
||||
background: rgba(59, 130, 246, 0.02);
|
||||
}
|
||||
|
||||
.researchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.researchCard {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(124, 58, 237, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.researchNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.researchCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.researchCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.researchLink {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.researchLink:hover {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
|
||||
border-top: 1px solid rgba(124, 58, 237, 0.15);
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.15);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.speakerSection,
|
||||
.themesSection,
|
||||
.researchSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './ai-security-summit-2025.module.css';
|
||||
|
||||
export default function AISecuritySummit2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="AI Security Summit 2025"
|
||||
description="Promptfoo at AI Security Summit 2025 (Oct 22–23) at The Westin St. Francis, San Francisco. Ian Webster joins the panel 'Using AI for Offensive Security Testing.'"
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at AI Security Summit 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Promptfoo at AI Security Summit 2025 (Oct 22–23, San Francisco). Community sponsor with Ian Webster on the panel 'Using AI for Offensive Security Testing.'"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:url"
|
||||
content="https://www.promptfoo.dev/events/ai-security-summit-2025"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/ai-security-summit-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/ai-security-summit-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="AI Security Summit 2025, LLM security, AI red teaming, San Francisco, AI vulnerabilities, machine learning security"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/ai-security-summit-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.summitPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/ai-security-summit-2025.jpg"
|
||||
alt="AI Security Summit 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🧠</span>
|
||||
AI Security Summit 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
The Future of <span className={styles.highlight}>AI Security</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Two days of leadership talks and practitioner sessions focused on real-world AI
|
||||
security. Catch Ian Webster on the panel "Using AI for Offensive Security Testing."
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>October 22-23, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Westin St. Francis, San Francisco</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Panel Speaker</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#highlights"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#highlights')}
|
||||
>
|
||||
View Highlights
|
||||
</a>
|
||||
<Link to="/docs/red-team/" className={styles.secondaryCta}>
|
||||
Learn Red Teaming
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Speaker Spotlight */}
|
||||
<section className={styles.speakerSection} id="highlights">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Speaker Spotlight</h2>
|
||||
</div>
|
||||
<div className={styles.speakerCard}>
|
||||
<div className={styles.speakerInfo}>
|
||||
<div className={styles.speakerBadge}>Panel Speaker</div>
|
||||
<h3 className={styles.speakerName}>Ian Webster</h3>
|
||||
<p className={styles.speakerRole}>CEO & Co-founder, Promptfoo</p>
|
||||
<p className={styles.speakerBio}>
|
||||
Ian joined industry leaders for the panel "Using AI for Offensive Security
|
||||
Testing," covering how teams can use automation to discover LLM and agent
|
||||
vulnerabilities earlier in the lifecycle.
|
||||
</p>
|
||||
<div className={styles.speakerTopics}>
|
||||
<span className={styles.topic}>LLM Security</span>
|
||||
<span className={styles.topic}>Red Teaming</span>
|
||||
<span className={styles.topic}>Enterprise AI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Themes */}
|
||||
<section className={styles.themesSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Key Themes</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Critical topics shaping the AI security landscape in 2025 and beyond.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.themesGrid}>
|
||||
<div className={styles.themeCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>Adversarial AI</h3>
|
||||
<p>
|
||||
Understanding how attackers exploit LLMs through prompt injection, jailbreaking,
|
||||
and novel attack vectors targeting foundation models.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.themeCard}>
|
||||
<div className={styles.cardIcon}>🔐</div>
|
||||
<h3>Defense Strategies</h3>
|
||||
<p>
|
||||
Building robust guardrails and implementing comprehensive red teaming programs to
|
||||
secure AI applications at scale.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.themeCard}>
|
||||
<div className={styles.cardIcon}>🏢</div>
|
||||
<h3>Enterprise Readiness</h3>
|
||||
<p>
|
||||
Navigating compliance requirements, governance frameworks, and security best
|
||||
practices for production AI systems.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.themeCard}>
|
||||
<div className={styles.cardIcon}>🔮</div>
|
||||
<h3>Future Threats</h3>
|
||||
<p>
|
||||
Anticipating emerging vulnerabilities in multimodal models, agents, and
|
||||
next-generation AI architectures.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Research Highlights */}
|
||||
<section className={styles.researchSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Research Highlights</h2>
|
||||
</div>
|
||||
<div className={styles.researchGrid}>
|
||||
<div className={styles.researchCard}>
|
||||
<div className={styles.researchNumber}>01</div>
|
||||
<h3>Automated Red Teaming</h3>
|
||||
<p>
|
||||
Demonstrated how open-source tools can systematically discover vulnerabilities in
|
||||
LLM applications through automated adversarial testing.
|
||||
</p>
|
||||
<Link to="/docs/red-team/quickstart/" className={styles.researchLink}>
|
||||
Try it yourself →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.researchCard}>
|
||||
<div className={styles.researchNumber}>02</div>
|
||||
<h3>Jailbreak Patterns</h3>
|
||||
<p>
|
||||
Analyzed common jailbreak techniques and their effectiveness across different
|
||||
model providers, revealing gaps in current safety measures.
|
||||
</p>
|
||||
<Link to="/docs/red-team/strategies/" className={styles.researchLink}>
|
||||
View strategies →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.researchCard}>
|
||||
<div className={styles.researchNumber}>03</div>
|
||||
<h3>Data Exfiltration</h3>
|
||||
<p>
|
||||
Showcased novel methods attackers use to extract sensitive information from RAG
|
||||
systems and enterprise chatbots.
|
||||
</p>
|
||||
<Link to="/docs/red-team/plugins/pii/" className={styles.researchLink}>
|
||||
Explore plugins →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Oct 22–23</div>
|
||||
<div className={styles.statLabel}>Dates</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>SF</div>
|
||||
<div className={styles.statLabel}>Westin St. Francis</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>2</div>
|
||||
<div className={styles.statLabel}>Days</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>1</div>
|
||||
<div className={styles.statLabel}>Mission</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Secure Your AI</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Start red teaming your LLM applications today with Promptfoo's open-source security
|
||||
testing framework.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/docs/red-team/quickstart/" className={styles.primaryCta}>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link to="https://github.com/promptfoo/promptfoo" className={styles.secondaryCta}>
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
/* Enable smooth scrolling globally */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.blackhatPage {
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(45deg, #0a0a0a 0%, #1a0a1a 25%, #0a0a1a 50%, #1a0a0a 75%, #0a0a0a 100%),
|
||||
radial-gradient(ellipse at top left, rgba(255, 0, 128, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(255, 0, 255, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 80%, rgba(0, 255, 255, 0.1) 0%, transparent 30%),
|
||||
radial-gradient(circle at 80% 20%, rgba(128, 0, 255, 0.1) 0%, transparent 30%),
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 0, 128, 0.05) 0%, transparent 70%);
|
||||
background-size:
|
||||
400% 400%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%;
|
||||
animation: gradientShift 20s ease infinite;
|
||||
}
|
||||
|
||||
.blackhatPage::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
transparent 0%,
|
||||
rgba(10, 10, 10, 0.4) 40%,
|
||||
rgba(10, 10, 10, 0.4) 60%,
|
||||
transparent 100%
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(10, 10, 10, 0.4) 40%,
|
||||
rgba(10, 10, 10, 0.4) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
animation: scanlines 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%,
|
||||
100% {
|
||||
background-position:
|
||||
0% 50%,
|
||||
0% 0%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
100% 0%,
|
||||
50% 50%;
|
||||
}
|
||||
25% {
|
||||
background-position:
|
||||
50% 100%,
|
||||
50% 0%,
|
||||
50% 100%,
|
||||
50% 50%,
|
||||
50% 50%,
|
||||
50% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
100% 50%,
|
||||
100% 0%,
|
||||
0% 100%,
|
||||
100% 0%,
|
||||
0% 100%,
|
||||
50% 50%;
|
||||
}
|
||||
75% {
|
||||
background-position:
|
||||
50% 0%,
|
||||
50% 100%,
|
||||
50% 0%,
|
||||
50% 50%,
|
||||
50% 50%,
|
||||
50% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scanlines {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 4rem 2rem;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroBackground::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(255, 0, 128, 0.1);
|
||||
border: 1px solid rgba(255, 0, 128, 0.3);
|
||||
border-radius: 2rem;
|
||||
color: #ff0080;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 2rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #cccccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #999;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 128, 0.3);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 0, 128, 0.4);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: transparent;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #999;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: #ff0080;
|
||||
}
|
||||
|
||||
/* Demo Section - New Kickass Design */
|
||||
.demoSection {
|
||||
position: relative;
|
||||
padding: 8rem 2rem;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.demoBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.demoBackground::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(255, 0, 128, 0.03) 0%,
|
||||
rgba(255, 0, 255, 0.01) 25%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: rotate 30s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.demoContainer {
|
||||
position: relative;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.demoHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
.demoTitle {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #ff0080 40%, #ff00ff 60%, #ffffff 100%);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradient 8s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.demoSubtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.demoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 3rem;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.demoCard {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
animation: slideInUp 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.demoCard[data-demo='1'] {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.demoCard[data-demo='2'] {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.demoCard[data-demo='3'] {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.demoCard[data-demo='4'] {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) rotateX(-10deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.demoCardInner {
|
||||
position: relative;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
backdrop-filter: blur(10px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.demoCard:hover .demoCardInner {
|
||||
transform: translateY(-10px) scale(1.02);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 0, 128, 0.05) 100%);
|
||||
}
|
||||
|
||||
.demoCardBorder {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 1.5rem;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 0, 128, 0.4),
|
||||
rgba(255, 0, 255, 0.4),
|
||||
rgba(0, 255, 255, 0.2)
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.demoCard:hover .demoCardBorder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.demoIconWrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.demoIcon {
|
||||
font-size: 3rem !important;
|
||||
color: #ff0080;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
filter: drop-shadow(0 0 20px rgba(255, 0, 128, 0.5));
|
||||
animation: iconFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.demoCard:nth-child(even) .demoIcon {
|
||||
animation-delay: 1.5s;
|
||||
}
|
||||
|
||||
@keyframes iconFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.demoIconGlow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 128, 0.3) 0%,
|
||||
rgba(255, 0, 255, 0.2) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.demoContent h3 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.demoContent p {
|
||||
color: #c0c0c0;
|
||||
line-height: 1.8;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demoTag {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
border-radius: 2rem;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 128, 0.3);
|
||||
}
|
||||
|
||||
/* Keep the original container class for other sections */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Calendar Section */
|
||||
.calendarSection {
|
||||
padding: 6rem 2rem;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.calendarSubtitle {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.calendarWrapper {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
/* Why Section */
|
||||
.whySection {
|
||||
padding: 6rem 2rem;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: #999;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Final CTA */
|
||||
.finalCta {
|
||||
padding: 6rem 2rem;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.finalCta h2 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #cccccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.finalCta p {
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroTitle {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.benefitItem {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.benefitIcon {
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.demoGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.demoTitle {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.demoSubtitle {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.demoCardInner {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.demoIconWrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.demoIcon {
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import Cal, { getCalApi } from '@calcom/embed-react';
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './blackhat-2025.module.css';
|
||||
|
||||
export default function BlackHat2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
useEffect(() => {
|
||||
// Cal.com setup
|
||||
(async function () {
|
||||
const cal = await getCalApi({ namespace: 'promptfoo-at-blackhat' });
|
||||
cal('ui', { hideEventTypeDetails: false, layout: 'month_view' });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80; // Offset for fixed header
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at Black Hat USA 2025"
|
||||
description="Meet Promptfoo at Black Hat USA 2025. Schedule a demo to see how we're revolutionizing AI security testing and red teaming for enterprise LLM applications."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at Black Hat USA 2025 | AI Security" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Visit booth #4712 at Black Hat USA 2025. See live demos of AI vulnerability testing, automated red teaming, and LLM security scanning. Schedule your meeting Aug 5-7 in Las Vegas."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/blackhat-2025.jpg"
|
||||
/>
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/blackhat-2025" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Promptfoo" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Promptfoo at Black Hat USA 2025 | AI Security" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Visit booth #4712. Live demos of AI vulnerability testing & automated red teaming. Aug 5-7, Las Vegas."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/blackhat-2025.jpg"
|
||||
/>
|
||||
<meta name="twitter:site" content="@promptfoo" />
|
||||
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Black Hat USA 2025, AI security, LLM security, prompt injection, jailbreaking, red teaming, AI vulnerability testing, OWASP LLM Top 10"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/blackhat-2025" />
|
||||
</Head>
|
||||
<main className={styles.blackhatPage}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroBackground}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>Black Hat USA 2025</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Security for AI used by
|
||||
<br />
|
||||
<span className={styles.highlight}>
|
||||
{SITE_CONSTANTS.USER_COUNT_DISPLAY} Developers
|
||||
</span>
|
||||
</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Join us at Black Hat USA to see how Promptfoo helps security teams find and fix LLM
|
||||
vulnerabilities before attackers do.
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<a
|
||||
href="#schedule-demo"
|
||||
className={styles.primaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#schedule-demo')}
|
||||
>
|
||||
Schedule a Demo at Black Hat
|
||||
</a>
|
||||
<a
|
||||
href="#learn-more"
|
||||
className={styles.secondaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#learn-more')}
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>August 5-7, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Mandalay Bay, Las Vegas</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Booth #4712</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.eventDetails} style={{ marginTop: '1rem' }}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span style={{ fontWeight: 'bold', color: '#ff6b6b' }}>
|
||||
<Link
|
||||
to="https://www.blackhat.com/us-25/arsenal/schedule/index.html#promptfoo-44648"
|
||||
style={{ color: 'inherit' }}
|
||||
>
|
||||
Arsenal Labs - Aug 6
|
||||
</Link>
|
||||
{' & '}
|
||||
<Link
|
||||
to="https://www.blackhat.com/us-25/arsenal/schedule/#promptfoo-47875"
|
||||
style={{ color: 'inherit' }}
|
||||
>
|
||||
7
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span style={{ fontWeight: 'bold', color: '#ff6b6b' }}>
|
||||
AI Stage Talk: Aug 6, 1:30 PM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We'll Show Section */}
|
||||
<section className={styles.demoSection} id="learn-more">
|
||||
<div className={styles.demoBackground}>
|
||||
<div className={styles.demoContainer}>
|
||||
<div className={styles.demoHeader}>
|
||||
<h2 className={styles.demoTitle}>Live From Vegas</h2>
|
||||
</div>
|
||||
<div className={styles.demoGrid}>
|
||||
<div className={styles.demoCard} data-demo="1">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<SecurityIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Real-Time Attack Demonstrations</h3>
|
||||
<p>
|
||||
Watch our security researchers perform demonstrations of prompt injection,
|
||||
jailbreaking, and data exfiltration attacks. See how Promptfoo helps you
|
||||
detect these threats early, reproduce them in testing, and track fixes over
|
||||
time.
|
||||
</p>
|
||||
<div className={styles.demoTag}>LIVE DEMO</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
<div className={styles.demoCard} data-demo="2">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<BugReportIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>AI-Powered Red Team Automation</h3>
|
||||
<p>
|
||||
LLM-powered red teaming can generate thousands of application-specific
|
||||
attack variations to uncover vulnerabilities that static scanners miss.
|
||||
Experience how continuous testing adapts to your unique architecture and use
|
||||
cases.
|
||||
</p>
|
||||
<div className={styles.demoTag}>INTERACTIVE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
<div className={styles.demoCard} data-demo="3">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<SpeedIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Seamless CI/CD Security Integration</h3>
|
||||
<p>
|
||||
See live demos of our GitHub Actions, GitLab CI, and Jenkins integrations.
|
||||
Learn how Fortune 500 companies automatically catch LLM vulnerabilities in
|
||||
development before they reach production environments.
|
||||
</p>
|
||||
<div className={styles.demoTag}>HANDS-ON</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
<div className={styles.demoCard} data-demo="4">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<AssessmentIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Compliance & Risk Assessment</h3>
|
||||
<p>
|
||||
Get automated reports for OWASP LLM Top 10, NIST AI RMF, and EU AI Act
|
||||
compliance. Our platform provides actionable remediation guidance and tracks
|
||||
your security posture over time with executive-ready dashboards.
|
||||
</p>
|
||||
<div className={styles.demoTag}>SHOWCASE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Calendar Section */}
|
||||
<section className={styles.calendarSection} id="schedule-demo">
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionTitle}>Meet us at Black Hat</h2>
|
||||
<p className={styles.calendarSubtitle}>
|
||||
Book a 30-minute demo slot to see Promptfoo in action. Our security experts will show
|
||||
you how to find and fix vulnerabilities in your LLM applications.
|
||||
</p>
|
||||
<div className={styles.calendarWrapper}>
|
||||
<Cal
|
||||
namespace="promptfoo-at-blackhat"
|
||||
calLink="team/promptfoo/promptfoo-at-blackhat"
|
||||
style={{ width: '100%', height: '100%', overflow: 'scroll' }}
|
||||
config={{ layout: 'month_view' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why Meet Us Section */}
|
||||
<section className={styles.whySection}>
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionTitle}>Why Security Teams Choose Promptfoo</h2>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{`${SITE_CONSTANTS.USER_COUNT_DISPLAY}+`}</div>
|
||||
<div className={styles.statLabel}>Developers</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>80+</div>
|
||||
<div className={styles.statLabel}>Fortune 500 Companies</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>#4712</div>
|
||||
<div className={styles.statLabel}>AI Zone Pavilion</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Aug 6–7</div>
|
||||
<div className={styles.statLabel}>Business Hall</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.finalCta}>
|
||||
<div className={styles.container}>
|
||||
<h2>Don't Leave Your AI Vulnerable</h2>
|
||||
<p>
|
||||
Join hundreds of security teams who trust Promptfoo to protect their LLM applications.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<a
|
||||
href="#schedule-demo"
|
||||
className={styles.primaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#schedule-demo')}
|
||||
>
|
||||
Book Your Demo Slot
|
||||
</a>
|
||||
<Link to="/security" className={styles.secondaryButton}>
|
||||
Explore Our Security Platform
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/* Black Hat 2026 - Cyber Theme */
|
||||
/* Color Palette: Magenta (#ff0080), purple (#ff00ff), dark */
|
||||
|
||||
.blackhatPage {
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(45deg, #0a0a0a 0%, #1a0a1a 25%, #0a0a1a 50%, #1a0a0a 75%, #0a0a0a 100%),
|
||||
radial-gradient(ellipse at top left, rgba(255, 0, 128, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(255, 0, 255, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 80%, rgba(0, 255, 255, 0.1) 0%, transparent 30%),
|
||||
radial-gradient(circle at 80% 20%, rgba(128, 0, 255, 0.1) 0%, transparent 30%);
|
||||
background-size:
|
||||
400% 400%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%;
|
||||
animation: gradientShift 20s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0%,
|
||||
100% {
|
||||
background-position:
|
||||
0% 50%,
|
||||
0% 0%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
100% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position:
|
||||
100% 50%,
|
||||
100% 0%,
|
||||
0% 100%,
|
||||
100% 0%,
|
||||
0% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 4rem 2rem;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(255, 0, 128, 0.1);
|
||||
border: 1px solid rgba(255, 0, 128, 0.3);
|
||||
border-radius: 2rem;
|
||||
color: #ff0080;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 2rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #cccccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #999;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 128, 0.3);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 0, 128, 0.4);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: transparent;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #999;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: #ff0080;
|
||||
}
|
||||
|
||||
/* Demo Section */
|
||||
.demoSection {
|
||||
position: relative;
|
||||
padding: 8rem 2rem;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.demoBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.demoContainer {
|
||||
position: relative;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.demoHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
|
||||
.demoTitle {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #ff0080 40%, #ff00ff 60%, #ffffff 100%);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: gradient 8s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.demoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 3rem;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.demoCard {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
animation: slideInUp 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.demoCard[data-demo='1'] {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.demoCard[data-demo='2'] {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.demoCard[data-demo='3'] {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.demoCard[data-demo='4'] {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(50px) rotateX(-10deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) rotateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.demoCardInner {
|
||||
position: relative;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
border-radius: 1.5rem;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
backdrop-filter: blur(10px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.demoCard:hover .demoCardInner {
|
||||
transform: translateY(-10px) scale(1.02);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 0, 128, 0.05) 100%);
|
||||
}
|
||||
|
||||
.demoCardBorder {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 1.5rem;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 0, 128, 0.4),
|
||||
rgba(255, 0, 255, 0.4),
|
||||
rgba(0, 255, 255, 0.2)
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.demoCard:hover .demoCardBorder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.demoIconWrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.demoIcon {
|
||||
font-size: 3rem !important;
|
||||
color: #ff0080;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
filter: drop-shadow(0 0 20px rgba(255, 0, 128, 0.5));
|
||||
animation: iconFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes iconFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.demoIconGlow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 128, 0.3) 0%,
|
||||
rgba(255, 0, 255, 0.2) 40%,
|
||||
transparent 70%
|
||||
);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.demoContent h3 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1rem;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.demoContent p {
|
||||
color: #c0c0c0;
|
||||
line-height: 1.8;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.demoTag {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
border-radius: 2rem;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 15px rgba(255, 0, 128, 0.3);
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Why Section */
|
||||
.whySection {
|
||||
padding: 6rem 2rem;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, #ff0080 0%, #ff00ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: #999;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Final CTA */
|
||||
.finalCta {
|
||||
padding: 6rem 2rem;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.finalCta h2 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #cccccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.finalCta p {
|
||||
font-size: 1.25rem;
|
||||
color: #999;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(255, 0, 128, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #ff0080;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroTitle {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.demoGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.demoTitle {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.demoCardInner {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.demoIconWrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.demoIcon {
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
|
||||
.finalCta h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import SecurityIcon from '@mui/icons-material/Security';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './blackhat-2026.module.css';
|
||||
|
||||
export default function BlackHat2026(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.scrollY - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at Black Hat USA 2026"
|
||||
description="This is the biggest stage in offensive security. Meet Promptfoo at Black Hat for live AI attacks, automated red teaming, and practical defenses."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at Black Hat USA 2026 | AI Security" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="This is the biggest stage in offensive security. Meet Promptfoo at Black Hat for live AI attacks, automated red teaming, and practical defenses. Aug 1-6, Las Vegas."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/blackhat-2026.jpg"
|
||||
/>
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/blackhat-2026" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Promptfoo at Black Hat USA 2026 | AI Security" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Live demos of AI vulnerability testing & automated red teaming. Aug 1-6, Las Vegas."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/blackhat-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Black Hat USA 2026, AI security, LLM security, prompt injection, jailbreaking, red teaming, AI vulnerability testing, OWASP LLM Top 10"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/blackhat-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.blackhatPage}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroBackground}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>Black Hat USA 2026</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Break Your AI
|
||||
<br />
|
||||
<span className={styles.highlight}>Before Attackers Do</span>
|
||||
</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
This is the biggest stage in offensive security. Meet Promptfoo at Black Hat to see
|
||||
live AI attacks, automated red teaming workflows, and practical defenses you can
|
||||
deploy now.
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<a
|
||||
href="#learn-more"
|
||||
className={styles.primaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#learn-more')}
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<Link to="/contact" className={styles.secondaryButton}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>August 1-6, 2026</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Mandalay Bay, Las Vegas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What to Expect Section */}
|
||||
<section className={styles.demoSection} id="learn-more">
|
||||
<div className={styles.demoBackground}>
|
||||
<div className={styles.demoContainer}>
|
||||
<div className={styles.demoHeader}>
|
||||
<h2 className={styles.demoTitle}>What to Expect</h2>
|
||||
</div>
|
||||
<div className={styles.demoGrid}>
|
||||
<div className={styles.demoCard} data-demo="1">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<SecurityIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Live AI Attack Demos</h3>
|
||||
<p>
|
||||
Prompt injection, jailbreaks, data exfiltration, and tool-use exploits
|
||||
against real-world applications.
|
||||
</p>
|
||||
<div className={styles.demoTag}>LIVE DEMO</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
<div className={styles.demoCard} data-demo="2">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<BugReportIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Automated Red Teaming</h3>
|
||||
<p>
|
||||
See how teams scale probing across models, prompts, and releases with zero
|
||||
manual effort.
|
||||
</p>
|
||||
<div className={styles.demoTag}>INTERACTIVE</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
<div className={styles.demoCard} data-demo="3">
|
||||
<div className={styles.demoCardInner}>
|
||||
<div className={styles.demoIconWrapper}>
|
||||
<SpeedIcon className={styles.demoIcon} />
|
||||
<div className={styles.demoIconGlow} />
|
||||
</div>
|
||||
<div className={styles.demoContent}>
|
||||
<h3>Defenses That Ship</h3>
|
||||
<p>
|
||||
Guardrails, detection rules, and remediation guidance you can take home.
|
||||
</p>
|
||||
<div className={styles.demoTag}>HANDS-ON</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.demoCardBorder} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why Section */}
|
||||
<section className={styles.whySection}>
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionTitle}>Why Security Teams Choose Promptfoo</h2>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{SITE_CONSTANTS.USER_COUNT_DISPLAY}+</div>
|
||||
<div className={styles.statLabel}>Developers</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>80+</div>
|
||||
<div className={styles.statLabel}>Fortune 500 Companies</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Aug 1-6</div>
|
||||
<div className={styles.statLabel}>Las Vegas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.finalCta}>
|
||||
<div className={styles.container}>
|
||||
<h2>Attending Black Hat?</h2>
|
||||
<p>Schedule a meeting to discuss your use case and see a tailored demo.</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/contact" className={styles.primaryButton}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<Link to="https://discord.gg/promptfoo" className={styles.secondaryButton}>
|
||||
Join our Discord
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/* BSides Seattle 2025 - PNW Theme */
|
||||
/* Color Palette: Forest green (#228B22), slate gray (#475569), coffee brown (#6F4E37) */
|
||||
|
||||
.bsidesPage {
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.3) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(15, 23, 42, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Rain Effect */
|
||||
.rainContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.raindrop {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
width: 2px;
|
||||
height: 15px;
|
||||
background: linear-gradient(transparent, rgba(148, 163, 184, 0.6));
|
||||
animation: rain linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rain {
|
||||
to {
|
||||
transform: translateY(420px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(34, 139, 34, 0.2);
|
||||
border: 1px solid rgba(34, 139, 34, 0.4);
|
||||
color: #86efac;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #6f4e37 0%, #8b7355 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(34, 139, 34, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(34, 197, 94, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #86efac;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(34, 139, 34, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(34, 139, 34, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.spiritSection,
|
||||
.sharedSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.spiritSection {
|
||||
background: rgba(34, 139, 34, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Spirit/Shared Grid */
|
||||
.spiritGrid,
|
||||
.sharedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.spiritCard,
|
||||
.sharedCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(34, 139, 34, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.spiritCard:hover,
|
||||
.sharedCard:hover {
|
||||
border-color: rgba(34, 139, 34, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.spiritCard h3,
|
||||
.sharedCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.spiritCard p,
|
||||
.sharedCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
background: rgba(34, 139, 34, 0.05);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #6f4e37 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(34, 139, 34, 0.08) 0%, rgba(111, 78, 55, 0.08) 100%);
|
||||
border: 1px solid rgba(34, 139, 34, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(34, 139, 34, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.raindrop {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spiritSection,
|
||||
.sharedSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './bsides-seattle-2025.module.css';
|
||||
|
||||
export default function BSidesSeattle2025(): React.ReactElement {
|
||||
const [rainEnabled, setRainEnabled] = useState(true);
|
||||
|
||||
const raindrops = useMemo(
|
||||
() =>
|
||||
[...Array(30)].map(() => ({
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDuration: `${0.5 + Math.random() * 0.5}s`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at BSides Seattle 2025"
|
||||
description="Promptfoo sponsored BSides Seattle 2025 and joined the PNW security community in Redmond."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at BSides Seattle 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Recap of Promptfoo at BSides Seattle 2025. Community-driven security, AI red teaming demos, and PNW vibes."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/bsides-seattle-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-seattle-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-seattle-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="BSides Seattle 2025, security conference, AI security, LLM security, Pacific Northwest, Seattle"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/bsides-seattle-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.bsidesPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/bsides-seattle-2025.jpg"
|
||||
alt="BSides Seattle 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
{rainEnabled && (
|
||||
<div className={styles.rainContainer}>
|
||||
{raindrops.map((drop, i) => (
|
||||
<div key={i} className={styles.raindrop} style={drop} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>☕</span>
|
||||
BSides Seattle 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Security, Community, <span className={styles.highlight}>Coffee</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
We sponsored and joined the Pacific Northwest security community for two days of
|
||||
hands-on learning, great conversations, and way too much caffeine.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>April 18-19, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Building 92, Redmond, WA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#recap"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#recap')}
|
||||
>
|
||||
See the Recap
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryCta}
|
||||
onClick={() => setRainEnabled(!rainEnabled)}
|
||||
>
|
||||
{rainEnabled ? '🌧️ Seattle Mode' : '☀️ Sunny (rare)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The BSides Spirit Section */}
|
||||
<section className={styles.spiritSection} id="recap">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>The BSides Spirit</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
BSides conferences are the heart of the security community. No corporate polish,
|
||||
just passionate people sharing knowledge.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.spiritGrid}>
|
||||
<div className={styles.spiritCard}>
|
||||
<div className={styles.cardIcon}>🌲</div>
|
||||
<h3>PNW Roots</h3>
|
||||
<p>
|
||||
Seattle's security community is tight-knit and welcoming. From Boeing to Amazon to
|
||||
countless startups, the talent here is incredible.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.spiritCard}>
|
||||
<div className={styles.cardIcon}>🤝</div>
|
||||
<h3>Community First</h3>
|
||||
<p>
|
||||
BSides isn't about vendor pitches. It's about sharing real knowledge, making
|
||||
connections, and helping each other level up.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.spiritCard}>
|
||||
<div className={styles.cardIcon}>☕</div>
|
||||
<h3>Fueled by Coffee</h3>
|
||||
<p>
|
||||
This is Seattle. The coffee flows freely, the conversations go deep, and nobody
|
||||
judges your third (or fourth) cup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We Shared Section */}
|
||||
<section className={styles.sharedSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What We Talked About</h2>
|
||||
</div>
|
||||
<div className={styles.sharedGrid}>
|
||||
<div className={styles.sharedCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>AI Red Teaming Walkthroughs</h3>
|
||||
<p>
|
||||
Quick walkthroughs and examples showing how to find vulnerabilities in LLM
|
||||
applications using open source tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.sharedCard}>
|
||||
<div className={styles.cardIcon}>🔓</div>
|
||||
<h3>Jailbreaking Demos</h3>
|
||||
<p>
|
||||
Live demonstrations of prompt injection, jailbreaking, and data exfiltration
|
||||
attacks against popular LLM providers.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.sharedCard}>
|
||||
<div className={styles.cardIcon}>💬</div>
|
||||
<h3>Hallway Conversations</h3>
|
||||
<p>
|
||||
Some of the best moments at BSides happen between sessions. We talked AI security
|
||||
challenges with teams from across the PNW.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>400+</div>
|
||||
<div className={styles.statLabel}>Attendees</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>∞</div>
|
||||
<div className={styles.statLabel}>Coffee Consumed</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>50+</div>
|
||||
<div className={styles.statLabel}>Conversations</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>0</div>
|
||||
<div className={styles.statLabel}>Sunny Days</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Join the Community</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Whether you're in Seattle or anywhere else, the AI security community is growing.
|
||||
Let's connect.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="https://discord.gg/promptfoo" className={styles.primaryCta}>
|
||||
Join Discord
|
||||
</Link>
|
||||
<Link to="https://github.com/promptfoo/promptfoo" className={styles.secondaryCta}>
|
||||
Star on GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
/* BSides Seattle 2026 - PNW Theme */
|
||||
/* Color Palette: Forest green (#228B22), evergreen (#2d5a3d), coffee brown (#6F4E37), moss (#8fbc8f) */
|
||||
|
||||
.bsidesPage {
|
||||
background: #0c1419;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center top;
|
||||
background: #1a3d2e;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(12, 20, 25, 0.2) 0%,
|
||||
rgba(12, 20, 25, 0.4) 50%,
|
||||
rgba(12, 20, 25, 0.9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(12, 20, 25, 0.98) 0%, transparent 100%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Rain Effect */
|
||||
.rainContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.raindrop {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
width: 2px;
|
||||
height: 18px;
|
||||
background: linear-gradient(transparent, rgba(180, 200, 210, 0.5));
|
||||
animation: rain linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rain {
|
||||
to {
|
||||
transform: translateY(500px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(111, 78, 55, 0.25);
|
||||
border: 1px solid rgba(111, 78, 55, 0.5);
|
||||
color: #d4a574;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #8fbc8f 0%, #6f8f6f 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
position: relative;
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(45, 90, 61, 0.2);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Countdown Timer */
|
||||
.countdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(45, 90, 61, 0.2) 0%, rgba(111, 78, 55, 0.15) 100%);
|
||||
border: 1px solid rgba(45, 90, 61, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #8fbc8f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdownLabel {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #228b22;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.countdownExpired {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, rgba(45, 90, 61, 0.25) 0%, rgba(111, 78, 55, 0.2) 100%);
|
||||
border: 1px solid rgba(45, 90, 61, 0.4);
|
||||
border-radius: 12px;
|
||||
color: #8fbc8f;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #6f8f6f;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #2d5a3d 0%, #1e4d2b 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(45, 90, 61, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #8fbc8f;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(45, 90, 61, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(45, 90, 61, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.weatherToggle {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1.5rem;
|
||||
background: rgba(12, 20, 25, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 50%;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.weatherToggle:hover {
|
||||
background: rgba(12, 20, 25, 0.9);
|
||||
border-color: rgba(143, 188, 143, 0.4);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Card Link */
|
||||
.cardLink {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: #8fbc8f;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.cardLink:hover {
|
||||
color: #a8d4a8;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.spiritSection,
|
||||
.sharedSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.spiritSection {
|
||||
background: rgba(45, 90, 61, 0.03);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Spirit/Shared Grid */
|
||||
.spiritGrid,
|
||||
.sharedGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.spiritCard,
|
||||
.sharedCard {
|
||||
background: rgba(12, 20, 25, 0.6);
|
||||
border: 1px solid rgba(45, 90, 61, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spiritCard:hover,
|
||||
.sharedCard:hover {
|
||||
border-color: rgba(45, 90, 61, 0.4);
|
||||
transform: translateY(-2px);
|
||||
background: rgba(12, 20, 25, 0.8);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cardIconSvg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #6f8f6f;
|
||||
}
|
||||
|
||||
.cardIconSvg svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.spiritCard h3,
|
||||
.sharedCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.spiritCard p,
|
||||
.sharedCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
background: linear-gradient(135deg, rgba(45, 90, 61, 0.06) 0%, rgba(111, 78, 55, 0.04) 100%);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #8fbc8f 0%, #6f4e37 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statNumberText {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #8fbc8f 0%, #6f4e37 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(45, 90, 61, 0.1) 0%, rgba(111, 78, 55, 0.08) 100%);
|
||||
border: 1px solid rgba(45, 90, 61, 0.25);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(45, 90, 61, 0.15);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #8fbc8f;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.raindrop {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
min-width: 60px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spiritSection,
|
||||
.sharedSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.weatherToggle {
|
||||
bottom: 0.5rem;
|
||||
right: 1rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './bsides-seattle-2026.module.css';
|
||||
|
||||
// Event configuration
|
||||
const EVENT_DATE = '2026-02-27T09:00:00-08:00';
|
||||
const EVENT_DATE_DISPLAY = 'February 27-28, 2026';
|
||||
const EVENT_LOCATION = 'Building 92, Redmond, WA';
|
||||
|
||||
// Icon components to reduce JSX verbosity
|
||||
const TargetIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChatIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 21.192a5.996 5.996 0 01-3.102-1.268.75.75 0 01.346-1.326 5.97 5.97 0 002.727-1.347A5.967 5.967 0 013 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
function CountdownTimer(): React.ReactElement {
|
||||
const [timeLeft, setTimeLeft] = useState({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
isExpired: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const targetDate = new Date(EVENT_DATE).getTime();
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date().getTime();
|
||||
const difference = targetDate - now;
|
||||
|
||||
if (difference > 0) {
|
||||
setTimeLeft({
|
||||
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
seconds: Math.floor((difference % (1000 * 60)) / 1000),
|
||||
isExpired: false,
|
||||
});
|
||||
} else {
|
||||
setTimeLeft({ days: 0, hours: 0, minutes: 0, seconds: 0, isExpired: true });
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (timeLeft.isExpired) {
|
||||
return (
|
||||
<div className={styles.countdownExpired}>
|
||||
<span>Event is happening now!</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.countdown}>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.days}</span>
|
||||
<span className={styles.countdownLabel}>Days</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.hours.toString().padStart(2, '0')}</span>
|
||||
<span className={styles.countdownLabel}>Hours</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Minutes</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BSidesSeattle2026(): React.ReactElement {
|
||||
const [rainEnabled, setRainEnabled] = useState(true);
|
||||
|
||||
const raindrops = useMemo(
|
||||
() =>
|
||||
[...Array(30)].map(() => ({
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDuration: `${0.5 + Math.random() * 0.5}s`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
useForcedTheme('dark');
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at BSides Seattle 2026"
|
||||
description="Meet the Promptfoo team at BSides Seattle for hands-on AI red teaming demos, hallway-track threat intel, and practical ways to harden LLM apps."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at BSides Seattle 2026" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Meet the Promptfoo team at BSides Seattle for hands-on AI red teaming demos and practical ways to harden LLM apps. Feb 27-28, Building 92, Redmond."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/bsides-seattle-2026" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-seattle-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-seattle-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="BSides Seattle 2026, security conference, AI security, LLM security, Pacific Northwest, Seattle, red teaming"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/bsides-seattle-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.bsidesPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/bsides-seattle-2026.jpg"
|
||||
alt="BSides Seattle 2026 - Red panda mascot in a PNW coffee shop with hackers"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
{rainEnabled && (
|
||||
<div className={styles.rainContainer}>
|
||||
{raindrops.map((drop, i) => (
|
||||
<div key={i} className={styles.raindrop} style={drop} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>☕</span>
|
||||
BSides Seattle 2026
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Red Teaming for AI <span className={styles.highlight}>in the PNW</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Meet Promptfoo Engineers and get live demos of AI red teaming: prompt injection,
|
||||
jailbreaks, and data exfiltration against real-world LLM apps. Bring your use case and
|
||||
leave with a testing plan you can run in CI.
|
||||
</p>
|
||||
|
||||
<CountdownTimer />
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{EVENT_DATE_DISPLAY}</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{EVENT_LOCATION}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<Link to="/contact" className={styles.primaryCta}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.weatherToggle}
|
||||
onClick={() => setRainEnabled(!rainEnabled)}
|
||||
aria-label={rainEnabled ? 'Disable rain effect' : 'Enable rain effect'}
|
||||
title={
|
||||
rainEnabled
|
||||
? 'Too much Seattle? Click for sun'
|
||||
: 'Missing the rain? Click to bring it back'
|
||||
}
|
||||
>
|
||||
{rainEnabled ? '🌧️' : '☀️'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What to Expect Section */}
|
||||
<section className={styles.spiritSection} id="learn-more">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What to Expect</h2>
|
||||
<p className={styles.sectionSubtitle}>Open-Source, Developer-First AI Red Teaming</p>
|
||||
</div>
|
||||
<div className={styles.spiritGrid}>
|
||||
<a
|
||||
href="https://www.promptfoo.dev/docs/category/red-teaming/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.spiritCard}
|
||||
>
|
||||
<div className={styles.cardIconSvg}>
|
||||
<RefreshIcon />
|
||||
</div>
|
||||
<h3>Automated Red Teaming Workflows</h3>
|
||||
<p>
|
||||
Learn how teams turn one-off testing into repeatable coverage across prompts,
|
||||
models, and releases.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.bsidesseattle.com/2026-sponsors.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.spiritCard}
|
||||
>
|
||||
<div className={styles.cardIconSvg}>
|
||||
<TargetIcon />
|
||||
</div>
|
||||
<h3>Attend our Talks</h3>
|
||||
<p>Join our Engineers for a session on Red Teaming for AI at 2:30PM each day.</p>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/invite/promptfoo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.spiritCard}
|
||||
>
|
||||
<div className={styles.cardIconSvg}>
|
||||
<ChatIcon />
|
||||
</div>
|
||||
<h3>Join our Discord</h3>
|
||||
<p>
|
||||
Connect with our open source community on Discord to talk with peer developers and
|
||||
security practitioners.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Attending BSides Seattle Section */}
|
||||
<section className={styles.sharedSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Attending BSides Seattle?</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Grab a slot for a quick walkthrough tailored to your stack.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.sharedGrid}>
|
||||
<div className={styles.sharedCard}>
|
||||
<div className={styles.cardIconSvg}>
|
||||
<CalendarIcon />
|
||||
</div>
|
||||
<h3>Schedule a Meeting</h3>
|
||||
<p>
|
||||
Book time for a short, technical walkthrough of AI red teaming for your specific
|
||||
use case.
|
||||
</p>
|
||||
<Link to="/contact" className={styles.cardLink}>
|
||||
Book a Time →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.sharedCard}>
|
||||
<div className={styles.cardIconSvg}>
|
||||
<ChatIcon />
|
||||
</div>
|
||||
<h3>Can't Make It?</h3>
|
||||
<p>
|
||||
Join our Discord community to connect with our team and the AI security community.
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.gg/promptfoo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.cardLink}
|
||||
>
|
||||
Join Discord →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{SITE_CONSTANTS.USER_COUNT_DISPLAY}+</div>
|
||||
<div className={styles.statLabel}>Developers</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{SITE_CONSTANTS.FORTUNE_500_COUNT}</div>
|
||||
<div className={styles.statLabel}>Fortune 500</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumberText}>Open Source</div>
|
||||
<div className={styles.statLabel}>MIT Licensed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>See You in Seattle</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Whether you're a local or flying in for the conference, we'd love to connect. Join
|
||||
our community and stay updated on our event plans.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<a
|
||||
href="https://discord.gg/promptfoo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.primaryCta}
|
||||
>
|
||||
Join Discord
|
||||
</a>
|
||||
<Link to="/contact" className={styles.secondaryCta}>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/* BSides SF 2025 - Golden Gate Theme */
|
||||
/* Color Palette: Golden Gate orange (#FF6B35), fog gray (#94a3b8), SF blue (#1e3a5f) */
|
||||
|
||||
.bsidesPage {
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.3) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(15, 23, 42, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 107, 53, 0.2);
|
||||
border: 1px solid rgba(255, 107, 53, 0.4);
|
||||
color: #fb923c;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #f97316 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 107, 53, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #f97316 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(255, 107, 53, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #fb923c;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255, 107, 53, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(255, 107, 53, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.recapSection,
|
||||
.whySection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.recapSection {
|
||||
background: rgba(255, 107, 53, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Recap Grid */
|
||||
.recapGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recapCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 107, 53, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recapCard:hover {
|
||||
border-color: rgba(255, 107, 53, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recapCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recapCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Why Grid */
|
||||
.whyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.whyItem {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(255, 107, 53, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.whyNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #f97316 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.whyItem h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.whyItem p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(249, 115, 22, 0.08) 100%);
|
||||
border: 1px solid rgba(255, 107, 53, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(255, 107, 53, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recapSection,
|
||||
.whySection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './bsides-sf-2025.module.css';
|
||||
|
||||
export default function BSidesSF2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at BSides SF 2025"
|
||||
description="Recap of Promptfoo at BSides San Francisco 2025. Community connections and AI security discussions during RSA week."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at BSides SF 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Recap of Promptfoo at BSides San Francisco 2025. Community-driven security and AI discussions."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/bsides-sf-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-sf-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-sf-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="BSides SF 2025, BSides San Francisco, security conference, AI security, hacker community, RSA week"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/bsides-sf-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.bsidesPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/bsides-sf-2025.jpg"
|
||||
alt="BSides SF 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🌉</span>
|
||||
BSides SF 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Community <span className={styles.highlight}>First</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
We connected with the security community at BSides San Francisco 2025, the grassroots
|
||||
conference that brings hackers together during RSA week.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>April 26-27, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>City View at Metreon, SF</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#recap"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#recap')}
|
||||
>
|
||||
View Recap
|
||||
</a>
|
||||
<Link to="/events" className={styles.secondaryCta}>
|
||||
All Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recap Section */}
|
||||
<section id="recap" className={styles.recapSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Event Recap</h2>
|
||||
<p className={styles.sectionSubtitle}>The best of BSides SF 2025</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.recapGrid}>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🤝</div>
|
||||
<h3>Community Connections</h3>
|
||||
<p>
|
||||
Met with security researchers, bug bounty hunters, and AI enthusiasts who are
|
||||
pushing the boundaries of AI security research.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>💬</div>
|
||||
<h3>AI Security Discussions</h3>
|
||||
<p>
|
||||
Engaged in deep conversations about LLM vulnerabilities, prompt injection, and the
|
||||
future of AI red teaming with the community.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🎤</div>
|
||||
<h3>Hallway Track</h3>
|
||||
<p>
|
||||
Some of the best conversations happened between sessions—the hallway track is
|
||||
where the real magic of BSides happens.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🍻</div>
|
||||
<h3>After-Hours Networking</h3>
|
||||
<p>
|
||||
Connected with the community at evening events, building relationships that extend
|
||||
beyond the conference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why BSides Section */}
|
||||
<section className={styles.whySection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Why We Love BSides</h2>
|
||||
<p className={styles.sectionSubtitle}>The heart of the security community</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.whyGrid}>
|
||||
<div className={styles.whyItem}>
|
||||
<div className={styles.whyNumber}>01</div>
|
||||
<h3>Grassroots Energy</h3>
|
||||
<p>
|
||||
BSides captures the authentic hacker spirit—community-organized, volunteer-run,
|
||||
and focused on sharing knowledge over selling products.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.whyItem}>
|
||||
<div className={styles.whyNumber}>02</div>
|
||||
<h3>Diverse Perspectives</h3>
|
||||
<p>
|
||||
From first-time speakers to industry veterans, BSides brings together voices you
|
||||
won't hear at bigger corporate conferences.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.whyItem}>
|
||||
<div className={styles.whyNumber}>03</div>
|
||||
<h3>Open Source Spirit</h3>
|
||||
<p>
|
||||
As an open-source company, we feel right at home in a community that values
|
||||
transparency, collaboration, and giving back.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.whyItem}>
|
||||
<div className={styles.whyNumber}>04</div>
|
||||
<h3>Real Conversations</h3>
|
||||
<p>
|
||||
No booth barriers—just genuine discussions about security challenges, tool
|
||||
recommendations, and shared experiences.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>See You at BSides SF 2026</h2>
|
||||
<p className={styles.ctaText}>
|
||||
We'll be back for BSides SF 2026. Join us for more community connections, AI
|
||||
security discussions, and hallway track conversations.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/events/bsides-sf-2026" className={styles.primaryCta}>
|
||||
BSides SF 2026 Details
|
||||
</Link>
|
||||
<Link to="/contact" className={styles.secondaryCta}>
|
||||
Get in Touch
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/* BSides SF 2026 - Golden Gate Theme */
|
||||
/* Color Palette: BSides orange (#F97316), purple (#8B5CF6), neon accents */
|
||||
|
||||
.bsidesPage {
|
||||
background: #0a0f1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 15, 26, 0.4) 0%, rgba(10, 15, 26, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(10, 15, 26, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
border: 1px solid rgba(249, 115, 22, 0.4);
|
||||
color: #fdba74;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #f97316 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heroDate {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #fdba74;
|
||||
}
|
||||
|
||||
.heroDivider {
|
||||
color: rgba(249, 115, 22, 0.5);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.heroVenue {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Countdown Timer */
|
||||
.countdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
|
||||
border: 1px solid rgba(249, 115, 22, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #fdba74;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdownLabel {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f97316;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaBlurb {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(249, 115, 22, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #c4b5fd;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(139, 92, 246, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.highlightsSection,
|
||||
.whySection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.highlightsSection {
|
||||
background: rgba(249, 115, 22, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Highlights Grid */
|
||||
.highlightsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.highlightCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(249, 115, 22, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.highlightCard:hover {
|
||||
border-color: rgba(249, 115, 22, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.highlightCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.highlightCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Why Grid */
|
||||
.whyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.whyItem {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(249, 115, 22, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.whyNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #f97316 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.whyItem h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.whyItem p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ctaCard {
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||||
border: 1px solid rgba(249, 115, 22, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctaCardTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.ctaCardText {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.countdownSeparator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.heroDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroDate,
|
||||
.heroVenue {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
min-width: 60px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.ctaCard {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.highlightsSection,
|
||||
.whySection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './bsides-sf-2026.module.css';
|
||||
|
||||
function CountdownTimer(): React.ReactElement {
|
||||
const [timeLeft, setTimeLeft] = useState({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const targetDate = new Date('2026-03-21T09:00:00-07:00').getTime();
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date().getTime();
|
||||
const difference = targetDate - now;
|
||||
|
||||
if (difference > 0) {
|
||||
setTimeLeft({
|
||||
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
seconds: Math.floor((difference % (1000 * 60)) / 1000),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.countdown}>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.days}</span>
|
||||
<span className={styles.countdownLabel}>Days</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.hours.toString().padStart(2, '0')}</span>
|
||||
<span className={styles.countdownLabel}>Hours</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Minutes</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BSidesSF2026(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at BSides SF 2026"
|
||||
description="Join Promptfoo at BSides San Francisco 2026. Community connections, AI security workshops, and hacker culture during RSA week."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at BSides SF 2026" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Join Promptfoo at BSides San Francisco 2026. Community-driven security and AI workshops."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/bsides-sf-2026" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-sf-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/bsides-sf-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="BSides SF 2026, BSides San Francisco, security conference, AI security, hacker community, RSA week"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/bsides-sf-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.bsidesPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/bsides-sf-2026.jpg"
|
||||
alt="BSides SF 2026"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🌉</span>
|
||||
BSidesSF 2026
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
BSides <span className={styles.highlight}>San Francisco</span>
|
||||
</h1>
|
||||
<div className={styles.heroMeta}>
|
||||
<span className={styles.heroDate}>March 21-22, 2026</span>
|
||||
<span className={styles.heroDivider}>•</span>
|
||||
<span className={styles.heroVenue}>San Francisco</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Meet our AI red teaming team and see live demos of how Promptfoo secures AI
|
||||
applications—from pre-deployment testing to production monitoring.
|
||||
</p>
|
||||
|
||||
<CountdownTimer />
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>March 21-22, 2026</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>San Francisco, CA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroCtas}>
|
||||
<p className={styles.ctaBlurb}>Book a time to talk to us</p>
|
||||
<Link to="/contact" className={styles.primaryCta}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section id="highlights" className={styles.highlightsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What to Expect</h2>
|
||||
<p className={styles.sectionSubtitle}>Community-driven AI security experiences</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.highlightsGrid}>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>AI Red Teaming</h3>
|
||||
<p>
|
||||
Compare notes on LLM attack vectors, jailbreak techniques, and integrating
|
||||
automated red teaming into your workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🤝</div>
|
||||
<h3>Connect with AI Experts</h3>
|
||||
<p>
|
||||
Meet AI security professionals working on evals, guardrails, and MCP
|
||||
security—learn how they're protecting AI applications in production.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🎬</div>
|
||||
<h3>See it in Action</h3>
|
||||
<p>
|
||||
Quick-fire demos on the latest AI security research, tools, and techniques from
|
||||
the AI security and red teaming experts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaGrid}>
|
||||
<div className={styles.ctaCard}>
|
||||
<h3 className={styles.ctaCardTitle}>Attending BSides SF?</h3>
|
||||
<p className={styles.ctaCardText}>
|
||||
Book a time to meet with our AI security and red teaming experts at the event.
|
||||
</p>
|
||||
<Link to="/contact" className={styles.primaryCta}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.ctaCard}>
|
||||
<h3 className={styles.ctaCardTitle}>Can't make it?</h3>
|
||||
<p className={styles.ctaCardText}>
|
||||
Join our Discord community to connect with our team and our community.
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.com/invite/promptfoo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.secondaryCta}
|
||||
>
|
||||
Join our Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/* DEFCON 2025 Cyberpunk x Star Wars Theme */
|
||||
|
||||
.defconPage {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Glitch effect background */
|
||||
.glitchEffect {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 255, 0, 0.03) 2px,
|
||||
rgba(0, 255, 0, 0.03) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Scanlines */
|
||||
.scanlines {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.25) 50%);
|
||||
background-size: 100% 4px;
|
||||
animation: scanlines 8s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes scanlines {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 2px solid #00ff00;
|
||||
color: #00ff00;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.2em;
|
||||
margin-bottom: 2rem;
|
||||
animation: neon 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes neon {
|
||||
from {
|
||||
box-shadow:
|
||||
0 0 10px #00ff00,
|
||||
0 0 20px #00ff00,
|
||||
0 0 30px #00ff00;
|
||||
}
|
||||
to {
|
||||
box-shadow:
|
||||
0 0 20px #00ff00,
|
||||
0 0 30px #00ff00,
|
||||
0 0 40px #00ff00;
|
||||
}
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(3rem, 8vw, 6rem);
|
||||
font-weight: bold;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 1.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Glitch text effect */
|
||||
.glitch {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.glitch::before,
|
||||
.glitch::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.glitch::before {
|
||||
animation: glitch-1 0.5s infinite;
|
||||
color: #00ff00;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.glitch::after {
|
||||
animation: glitch-2 0.5s infinite;
|
||||
color: #ff00ff;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
@keyframes glitch-1 {
|
||||
0% {
|
||||
clip: rect(44px, 450px, 56px, 0);
|
||||
transform: translate(0);
|
||||
}
|
||||
20% {
|
||||
clip: rect(20px, 450px, 30px, 0);
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
40% {
|
||||
clip: rect(85px, 450px, 95px, 0);
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
60% {
|
||||
clip: rect(10px, 450px, 20px, 0);
|
||||
transform: translate(-1px, 1px);
|
||||
}
|
||||
80% {
|
||||
clip: rect(65px, 450px, 75px, 0);
|
||||
transform: translate(1px, -1px);
|
||||
}
|
||||
100% {
|
||||
clip: rect(25px, 450px, 35px, 0);
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-2 {
|
||||
0% {
|
||||
clip: rect(65px, 450px, 75px, 0);
|
||||
transform: translate(0);
|
||||
}
|
||||
20% {
|
||||
clip: rect(30px, 450px, 40px, 0);
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
40% {
|
||||
clip: rect(80px, 450px, 90px, 0);
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
60% {
|
||||
clip: rect(15px, 450px, 25px, 0);
|
||||
transform: translate(1px, -1px);
|
||||
}
|
||||
80% {
|
||||
clip: rect(50px, 450px, 60px, 0);
|
||||
transform: translate(-1px, 1px);
|
||||
}
|
||||
100% {
|
||||
clip: rect(35px, 450px, 45px, 0);
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #ff00ff;
|
||||
text-shadow:
|
||||
0 0 20px #ff00ff,
|
||||
0 0 40px #ff00ff;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
color: #00ff00;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: #ff00ff;
|
||||
color: #000;
|
||||
box-shadow: 0 0 20px rgba(255, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: #ff44ff;
|
||||
color: #000;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.8);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
background: transparent;
|
||||
color: #00ff00;
|
||||
border: 2px solid #00ff00;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
|
||||
.buttonGlitch {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buttonGlitch:hover {
|
||||
animation: textGlitch 0.3s infinite;
|
||||
}
|
||||
|
||||
@keyframes textGlitch {
|
||||
0% {
|
||||
transform: translate(0);
|
||||
}
|
||||
20% {
|
||||
transform: translate(-2px, 2px);
|
||||
}
|
||||
40% {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
60% {
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
80% {
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Event Details */
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #00ff00;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Party Section */
|
||||
.partySection {
|
||||
padding: 5rem 0;
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.partyBackground {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.partyContainer {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.partyHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.partyTitle {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
color: #00ff00;
|
||||
margin: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
color: #ff00ff;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.partyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.partyCard {
|
||||
position: relative;
|
||||
background: #111;
|
||||
border: 1px solid #333;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.partyCard:hover {
|
||||
border-color: #00ff00;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.partyCardInner {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.partyEmoji {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.partyCard h3 {
|
||||
color: #ff00ff;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.partyCard p {
|
||||
color: #00ff00;
|
||||
opacity: 0.8;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.partyTag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
border: 1px solid #00ff00;
|
||||
color: #00ff00;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ASCII Art Section */
|
||||
.asciiSection {
|
||||
padding: 3rem 0;
|
||||
background: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asciiArt {
|
||||
color: #00ff00;
|
||||
font-size: clamp(0.5rem, 1.5vw, 1rem);
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
|
||||
animation: flicker 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Community Section */
|
||||
.communitySection {
|
||||
padding: 5rem 0;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
text-align: center;
|
||||
margin: 0 0 3rem;
|
||||
color: #ff00ff;
|
||||
text-shadow: 0 0 20px rgba(255, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 2rem;
|
||||
background: rgba(255, 0, 255, 0.05);
|
||||
border: 1px solid rgba(255, 0, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
background: rgba(255, 0, 255, 0.1);
|
||||
border-color: #ff00ff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: #ff00ff;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Final CTA */
|
||||
.finalCta {
|
||||
padding: 5rem 0;
|
||||
text-align: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.finalCta h2 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
margin: 0 0 1rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.finalCta p {
|
||||
font-size: 1.25rem;
|
||||
color: #00ff00;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation: cursorBlink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroTitle {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 0 auto 3rem;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
[data-theme='dark'] .defconPage {
|
||||
--ifm-background-color: #000;
|
||||
--ifm-navbar-background-color: #000;
|
||||
--ifm-footer-background-color: #000;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './defcon-2025.module.css';
|
||||
|
||||
export default function Defcon2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80; // Offset for fixed header
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo Party at DEF CON 33"
|
||||
description="Join the Promptfoo crew for a party at DEF CON 33. Network with AI security researchers, hackers, and the open source community. Free drinks, great vibes, and security war stories."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo Party at DEF CON 33 | AI Security Community" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="The AI security party you don't want to miss at DEF CON 33. Join hackers, researchers, and the Promptfoo team for drinks and demos. August 9, 2025 in Las Vegas."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/events/defcon-2025.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/defcon-2025" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Promptfoo" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Promptfoo Party at DEF CON 33" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="The AI security party at DEF CON 33. Free drinks, live demos. August 9, Las Vegas."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/events/defcon-2025.jpg" />
|
||||
<meta name="twitter:site" content="@promptfoo" />
|
||||
|
||||
<meta
|
||||
name="keywords"
|
||||
content="DEF CON 33, DEF CON 2025, AI security party, hacker party, LLM security, prompt injection, red team, Las Vegas"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/defcon-2025" />
|
||||
</Head>
|
||||
<main className={styles.defconPage}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroBackground}>
|
||||
<div className={styles.glitchEffect} />
|
||||
<div className={styles.scanlines} />
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>DEF CON 33</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
<span className={styles.glitch} data-text="PROMPTFOO">
|
||||
PROMPTFOO
|
||||
</span>
|
||||
<br />
|
||||
<span className={styles.highlight}>PARTY</span>
|
||||
</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Join hackers, security researchers, and the open source community for the AI
|
||||
security event of DEF CON at the galaxy's most iconic cantina. RSVP required.
|
||||
Capacity is limited.
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<a
|
||||
href="https://lu.ma/ljm23pj6?tk=qGE9ez&utm_source=pf-web"
|
||||
className={styles.primaryButton}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className={styles.buttonGlitch}>RSVP NOW</span>
|
||||
</a>
|
||||
<a
|
||||
href="#party-details"
|
||||
className={styles.secondaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#party-details')}
|
||||
>
|
||||
Party Details
|
||||
</a>
|
||||
</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Saturday, August 9, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>6:00 PM - 8:00 PM</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Millennium FANDOM Bar</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span style={{ fontWeight: 'bold', color: '#00ff00' }}>
|
||||
Free drinks (while supplies last)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What to Expect Section */}
|
||||
<section className={styles.partySection} id="party-details">
|
||||
<div className={styles.partyBackground}>
|
||||
<div className={styles.partyContainer}>
|
||||
<div className={styles.partyHeader}>
|
||||
<h2 className={styles.partyTitle}>
|
||||
<span className={styles.terminal}>$</span> cat party_details.txt
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.partyGrid}>
|
||||
<div className={styles.partyCard}>
|
||||
<div className={styles.partyCardInner}>
|
||||
<div className={styles.partyEmoji}>🍺</div>
|
||||
<h3>Open Bar</h3>
|
||||
<p>Free drinks on us! Beer, cocktails, and non-alcoholic options.</p>
|
||||
<div className={styles.partyTag}>[FREE_DRINKS]</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.partyCard}>
|
||||
<div className={styles.partyCardInner}>
|
||||
<div className={styles.partyEmoji}>⚔️</div>
|
||||
<h3>Mos Eisley Vibes</h3>
|
||||
<p>
|
||||
Party in a wretched hive of scum and villainy. Expect lightsabers and
|
||||
jailbroken LLMs.
|
||||
</p>
|
||||
<div className={styles.partyTag}>[CANTINA_MODE]</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.partyCard}>
|
||||
<div className={styles.partyCardInner}>
|
||||
<div className={styles.partyEmoji}>🤖</div>
|
||||
<h3>Rebel Alliance Meetup</h3>
|
||||
<p>Join the resistance against vulnerable AI systems.</p>
|
||||
<div className={styles.partyTag}>[HACK_THE_EMPIRE]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ASCII Art Section */}
|
||||
<section className={styles.asciiSection}>
|
||||
<div className={styles.container}>
|
||||
<pre className={styles.asciiArt}>
|
||||
{`
|
||||
____ __ ____
|
||||
/ __ \\________ ____ ___ ____ / /_/ __/___ ____
|
||||
/ /_/ / ___/ _ \\/ __ \`__ \\/ __ \\/ __/ /_/ __ \\/ __ \\
|
||||
/ ____/ / / /_/ / / / / / / /_/ / /_/ __/ /_/ / /_/ /
|
||||
/_/ /_/ \\___/_/ /_/ /_/ .___/\\__/_/ \\____/\\____/
|
||||
/_/
|
||||
x DEF CON 33
|
||||
`}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Community Section */}
|
||||
<section className={styles.communitySection}>
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.sectionTitle}>Join the Movement</h2>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{`${SITE_CONSTANTS.USER_COUNT_DISPLAY}+`}</div>
|
||||
<div className={styles.statLabel}>Open Source Users</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>∞</div>
|
||||
<div className={styles.statLabel}>Drinks Available</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>32M+</div>
|
||||
<div className={styles.statLabel}>Security Tests Run</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>1</div>
|
||||
<div className={styles.statLabel}>Party</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.finalCta}>
|
||||
<div className={styles.container}>
|
||||
<h2>
|
||||
<span className={styles.blink}>_</span> Don't Miss Out
|
||||
</h2>
|
||||
<p>
|
||||
Space is limited. RSVP now to secure your spot at the AI security party of DEF CON.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<a
|
||||
href="https://lu.ma/ljm23pj6?tk=qGE9ez&utm_source=pf-web"
|
||||
className={styles.primaryButton}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className={styles.buttonGlitch}>CLAIM YOUR SPOT</span>
|
||||
</a>
|
||||
<Link to="/docs/intro" className={styles.secondaryButton}>
|
||||
Learn About Promptfoo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
/* Gartner Security 2026 - Professional Enterprise Theme */
|
||||
/* Color Palette: Navy blue (#1e3a5f), gold (#d4a537), slate */
|
||||
|
||||
.gartnerPage {
|
||||
background: linear-gradient(180deg, #0a1628 0%, #0f1e36 50%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.heroBackground::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 30%, rgba(30, 58, 95, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 70%, rgba(212, 165, 55, 0.08) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* Risk Meter Animation */
|
||||
.riskMeterContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.riskMeter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(30, 58, 95, 0.3);
|
||||
border: 1px solid rgba(212, 165, 55, 0.2);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.riskMeterLabel {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.riskMeterBars {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.riskBar {
|
||||
width: 4px;
|
||||
background: #d4a537;
|
||||
border-radius: 2px;
|
||||
animation: riskPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.riskBar:nth-child(1) {
|
||||
height: 6px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.riskBar:nth-child(2) {
|
||||
height: 10px;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.riskBar:nth-child(3) {
|
||||
height: 14px;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.riskBar:nth-child(4) {
|
||||
height: 18px;
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.riskBar:nth-child(5) {
|
||||
height: 12px;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes riskPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.riskMeterStatus {
|
||||
font-size: 0.75rem;
|
||||
color: #22c55e;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(30, 58, 95, 0.3);
|
||||
border: 1px solid rgba(212, 165, 55, 0.3);
|
||||
border-radius: 100px;
|
||||
color: #d4a537;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2.5rem, 6vw, 4rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #e2e8f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #d4a537 0%, #f0c85a 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 750px;
|
||||
margin: 0 auto 2.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: linear-gradient(135deg, #d4a537 0%, #c49830 100%);
|
||||
color: #0a1628;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(212, 165, 55, 0.3);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(212, 165, 55, 0.4);
|
||||
color: #0a1628;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: transparent;
|
||||
color: #d4a537;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(212, 165, 55, 0.4);
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: rgba(212, 165, 55, 0.1);
|
||||
border-color: rgba(212, 165, 55, 0.6);
|
||||
color: #f0c85a;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #d4a537;
|
||||
}
|
||||
|
||||
/* Offer Section */
|
||||
.offerSection {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.offerGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.offerCard {
|
||||
background: rgba(30, 58, 95, 0.15);
|
||||
border: 1px solid rgba(30, 58, 95, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.offerCard:hover {
|
||||
border-color: rgba(212, 165, 55, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.offerCard h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.offerCard p {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
padding: 4rem 0;
|
||||
background: rgba(30, 58, 95, 0.08);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #d4a537 0%, #1e3a5f 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaSection {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(30, 58, 95, 0.2) 0%, rgba(212, 165, 55, 0.05) 100%);
|
||||
border: 1px solid rgba(212, 165, 55, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(30, 58, 95, 0.2);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #d4a537;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
min-height: auto;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './gartner-security-2026.module.css';
|
||||
|
||||
export default function GartnerSecurity2026(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.scrollY - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at Gartner Security & Risk Management Summit 2026"
|
||||
description="Turn AI risk into a measurable program. Meet Promptfoo for briefings on continuous red teaming, guardrails, and executive reporting."
|
||||
>
|
||||
<Head>
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Promptfoo at Gartner Security & Risk Management Summit 2026"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Turn AI risk into a measurable program. Meet Promptfoo for briefings on continuous red teaming, guardrails, and executive reporting. Jun 1-3, National Harbor MD."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/gartner-security-2026.jpg"
|
||||
/>
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/gartner-security-2026" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Promptfoo at Gartner Security & Risk Management Summit 2026"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Meet Promptfoo at Gartner Security 2026. Enterprise AI security, analyst briefings, and CISO discussions."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/gartner-security-2026.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Gartner Security 2026, risk management summit, AI security, enterprise security, CISO, Washington DC, AI governance"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/gartner-security-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.gartnerPage}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.heroBackground}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>Gartner Security & Risk Management Summit 2026</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Make AI Risk
|
||||
<br />
|
||||
<span className={styles.highlight}>Measurable</span>
|
||||
</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
If you're building an AI security program, we can help you move from ad hoc testing
|
||||
to continuous coverage. Meet Promptfoo for demos of automated red teaming, runtime
|
||||
guardrails, and reporting security leadership can track.
|
||||
</p>
|
||||
{/* Risk Meter Animation */}
|
||||
<div className={styles.riskMeterContainer}>
|
||||
<div className={styles.riskMeter}>
|
||||
<span className={styles.riskMeterLabel}>Risk Coverage</span>
|
||||
<div className={styles.riskMeterBars}>
|
||||
<div className={styles.riskBar} />
|
||||
<div className={styles.riskBar} />
|
||||
<div className={styles.riskBar} />
|
||||
<div className={styles.riskBar} />
|
||||
<div className={styles.riskBar} />
|
||||
</div>
|
||||
<span className={styles.riskMeterStatus}>Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroButtons}>
|
||||
<a
|
||||
href="#learn-more"
|
||||
className={styles.primaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#learn-more')}
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<Link to="/contact" className={styles.secondaryButton}>
|
||||
Schedule a Briefing
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>June 1-3, 2026</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>National Harbor, MD</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What to Expect Section */}
|
||||
<section className={styles.offerSection} id="learn-more">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What to Expect</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Tools and frameworks for security leaders building AI governance programs.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.offerGrid}>
|
||||
<div className={styles.offerCard}>
|
||||
<div className={styles.cardIcon}>📊</div>
|
||||
<h3>Operationalize AI Security</h3>
|
||||
<p>
|
||||
Learn how to move from ad hoc testing to structured coverage that tracks risk
|
||||
reduction over time.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.offerCard}>
|
||||
<div className={styles.cardIcon}>📋</div>
|
||||
<h3>Executive-ready Reporting</h3>
|
||||
<p>
|
||||
See dashboards and artifacts that translate technical findings into board-level
|
||||
summaries.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.offerCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>Briefings and Architecture Reviews</h3>
|
||||
<p>
|
||||
Book dedicated time with our team to walk through your program design and get
|
||||
personalized recommendations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{SITE_CONSTANTS.USER_COUNT_DISPLAY}+</div>
|
||||
<div className={styles.statLabel}>Developers</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>80+</div>
|
||||
<div className={styles.statLabel}>Fortune 500 Companies</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Jun 1-3</div>
|
||||
<div className={styles.statLabel}>National Harbor, MD</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Attending Gartner Security?</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Reach out to book a demo or architecture review during the summit.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/contact" className={styles.primaryButton}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<Link to="https://discord.gg/promptfoo" className={styles.secondaryButton}>
|
||||
Join our Discord
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/* HumanX 2026 - AI Innovation Theme */
|
||||
/* Color Palette: Deep purple (#7c3aed), cyan (#06b6d4), warm white */
|
||||
|
||||
.humanxPage {
|
||||
background: linear-gradient(180deg, #0f0a1f 0%, #1a1025 50%, #0f172a 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroBackground {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.heroBackground::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 30%, rgba(124, 58, 237, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 70%, rgba(6, 182, 212, 0.1) 0%, transparent 50%);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-2%, -2%);
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Thinking Animation */
|
||||
.aiThinkingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.aiThinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.aiThinkingDots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aiThinkingDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #06b6d4;
|
||||
border-radius: 50%;
|
||||
animation: aiPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.aiThinkingDot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.aiThinkingDot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes aiPulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Neural Nodes Floating Animation */
|
||||
.neuralNodes {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: rgba(124, 58, 237, 0.4);
|
||||
border-radius: 50%;
|
||||
animation: nodeFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(124, 58, 237, 0.3), transparent);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.node:nth-child(1) {
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.node:nth-child(2) {
|
||||
top: 60%;
|
||||
left: 85%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.node:nth-child(2)::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.node:nth-child(3) {
|
||||
top: 80%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.node:nth-child(4) {
|
||||
top: 30%;
|
||||
left: 90%;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.node:nth-child(4)::after {
|
||||
transform: rotate(200deg);
|
||||
}
|
||||
|
||||
.node:nth-child(5) {
|
||||
top: 70%;
|
||||
left: 5%;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
@keyframes nodeFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
||||
border-radius: 100px;
|
||||
color: #a78bfa;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2.5rem, 6vw, 4rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #e2e8f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 2.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
padding: 1rem 2.5rem;
|
||||
background: transparent;
|
||||
color: #a78bfa;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(124, 58, 237, 0.4);
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
border-color: rgba(124, 58, 237, 0.6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* Demo Section */
|
||||
.demoSection {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.demoCard {
|
||||
background: rgba(124, 58, 237, 0.05);
|
||||
border: 1px solid rgba(124, 58, 237, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.demoCard:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demoCard h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demoCard p {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
padding: 4rem 0;
|
||||
background: rgba(124, 58, 237, 0.03);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaSection {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(6, 182, 212, 0.05) 100%);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(124, 58, 237, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
min-height: auto;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.heroButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './humanx-2026.module.css';
|
||||
|
||||
export default function HumanX2026(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.scrollY - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at HumanX 2026"
|
||||
description="For AI leaders shipping real products: see how to evaluate and secure LLM apps and agents without slowing teams down."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at HumanX 2026 | AI Security" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="For AI leaders shipping real products: see how to evaluate and secure LLM apps and agents without slowing teams down. Apr 6-9, Moscone Center South, SF."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/events/humanx-2026.jpg" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/humanx-2026" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Promptfoo at HumanX 2026 | AI Security" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Meet Promptfoo at HumanX 2026. AI security demos, enterprise solutions, and networking with AI leaders."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/events/humanx-2026.jpg" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="HumanX 2026, AI conference, AI security, LLM security, enterprise AI, San Francisco, AI leadership"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/humanx-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.humanxPage}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles.hero}>
|
||||
{/* Floating Neural Nodes */}
|
||||
<div className={styles.neuralNodes}>
|
||||
<div className={styles.node} />
|
||||
<div className={styles.node} />
|
||||
<div className={styles.node} />
|
||||
<div className={styles.node} />
|
||||
<div className={styles.node} />
|
||||
</div>
|
||||
<div className={styles.heroBackground}>
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.badge}>HumanX 2026</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Ship AI
|
||||
<br />
|
||||
<span className={styles.highlight}>You Can Trust</span>
|
||||
</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
AI is moving fast. Security and evaluation need to keep up. Meet Promptfoo for live
|
||||
demos on testing and securing LLM features across copilots, RAG, and agents, before
|
||||
launch and continuously in production.
|
||||
</p>
|
||||
{/* AI Thinking Animation */}
|
||||
<div className={styles.aiThinkingContainer}>
|
||||
<div className={styles.aiThinking}>
|
||||
<span>AI is evaluating</span>
|
||||
<div className={styles.aiThinkingDots}>
|
||||
<div className={styles.aiThinkingDot} />
|
||||
<div className={styles.aiThinkingDot} />
|
||||
<div className={styles.aiThinkingDot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroButtons}>
|
||||
<a
|
||||
href="#learn-more"
|
||||
className={styles.primaryButton}
|
||||
onClick={(e) => handleSmoothScroll(e, '#learn-more')}
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<Link to="/contact" className={styles.secondaryButton}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>April 6-9, 2026</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg
|
||||
className={styles.icon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Moscone Center South, San Francisco</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We'll Show Section */}
|
||||
<section className={styles.demoSection} id="learn-more">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What to Expect</h2>
|
||||
<p className={styles.sectionSubtitle}>Practical workflows for AI teams.</p>
|
||||
</div>
|
||||
<div className={styles.demoGrid}>
|
||||
<div className={styles.demoCard}>
|
||||
<div className={styles.cardIcon}>📊</div>
|
||||
<h3>Evals That Measure What Matters</h3>
|
||||
<p>
|
||||
Reliability, safety, and policy adherence, with repeatable benchmarks you can
|
||||
track over time.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.demoCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>Red Teaming for Agents and Tools</h3>
|
||||
<p>
|
||||
Find risky actions, data exfiltration paths, and permission misuse before your
|
||||
users do.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.demoCard}>
|
||||
<div className={styles.cardIcon}>📋</div>
|
||||
<h3>Governance Without Bottlenecks</h3>
|
||||
<p>
|
||||
Turn testing results into auditable artifacts that support reviews, approvals, and
|
||||
rollouts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>{SITE_CONSTANTS.USER_COUNT_DISPLAY}+</div>
|
||||
<div className={styles.statLabel}>Developers</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>80+</div>
|
||||
<div className={styles.statLabel}>Fortune 500 Companies</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Apr 6-9</div>
|
||||
<div className={styles.statLabel}>San Francisco</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Attending HumanX?</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Book a short demo and we'll map a testing plan to your AI roadmap.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/contact" className={styles.primaryButton}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<Link to="https://discord.gg/promptfoo" className={styles.secondaryButton}>
|
||||
Join our Discord
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
.main {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.5) 0%, transparent 30%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .main {
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.3) 0%, transparent 30%);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 3.5rem 1.5rem 5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-radius: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1875rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.65;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--ifm-heading-color);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--ifm-color-emphasis-200) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Events Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 5rem 2rem;
|
||||
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.6) 100%);
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(148, 163, 184, 0.2) 0%, rgba(148, 163, 184, 0.05) 100%);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .empty {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.5) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
margin: 0 0 1.25rem 0;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s ease;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.resetButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(37, 99, 235, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.resetButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.cta {
|
||||
margin-top: 5rem;
|
||||
padding: 3.5rem;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 50%, #f5f9ff 100%);
|
||||
border-radius: 20px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cta::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 20% 30%, rgba(37, 99, 235, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 80% 70%, rgba(99, 102, 241, 0.08) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cta::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
border-radius: 20px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.25) 0%,
|
||||
rgba(99, 102, 241, 0.1) 50%,
|
||||
rgba(37, 99, 235, 0.15) 100%
|
||||
);
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cta {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.15) 0%,
|
||||
rgba(30, 41, 59, 0.8) 50%,
|
||||
rgba(15, 23, 42, 0.9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cta::before {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 20% 30%, rgba(37, 99, 235, 0.2) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 80% 70%, rgba(99, 102, 241, 0.15) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cta::after {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(37, 99, 235, 0.4) 0%,
|
||||
rgba(99, 102, 241, 0.15) 50%,
|
||||
rgba(37, 99, 235, 0.25) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 750;
|
||||
margin: 0 0 0.875rem 0;
|
||||
color: var(--ifm-heading-color);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.ctaDescription {
|
||||
font-size: 1.0625rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
margin: 0 0 1.75rem 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .ctaDescription {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.ctaButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.9375rem 2rem;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(37, 99, 235, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.ctaButton:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(37, 99, 235, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ctaButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 996px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 2.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
bottom: -1.25rem;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin-top: 3.5rem;
|
||||
padding: 2.5rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.cta::after {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.ctaDescription {
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small Mobile */
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 2rem 1rem 3.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 3.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.cta {
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimization */
|
||||
@media (hover: none) {
|
||||
.resetButton:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(37, 99, 235, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.resetButton:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ctaButton:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(37, 99, 235, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.ctaButton:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Layout from '@theme/Layout';
|
||||
import { EventCard, EventFilters, FeaturedEvent } from '../../components/Events';
|
||||
import {
|
||||
type Event,
|
||||
events,
|
||||
getFeaturedEvent,
|
||||
getPastEvents,
|
||||
getUpcomingEvents,
|
||||
} from '../../data/events';
|
||||
import styles from './index.module.css';
|
||||
|
||||
import type { FilterStatus, FilterYear } from '../../components/Events';
|
||||
|
||||
export default function EventsPage(): React.ReactElement {
|
||||
const [statusFilter, setStatusFilter] = useState<FilterStatus>('all');
|
||||
const [yearFilter, setYearFilter] = useState<FilterYear>('all');
|
||||
|
||||
const featuredEvent = getFeaturedEvent();
|
||||
|
||||
// Get available years from events
|
||||
const availableYears = useMemo(() => {
|
||||
const years = new Set(events.map((event) => new Date(event.startDate).getFullYear()));
|
||||
return Array.from(years).sort((a, b) => b - a);
|
||||
}, []);
|
||||
|
||||
// Filter events based on selected filters
|
||||
const filteredEvents = useMemo(() => {
|
||||
let result: Event[];
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter === 'upcoming') {
|
||||
result = getUpcomingEvents();
|
||||
} else if (statusFilter === 'past') {
|
||||
result = getPastEvents();
|
||||
} else {
|
||||
// Sort all events: upcoming first (by date asc), then past (by date desc)
|
||||
const upcoming = getUpcomingEvents();
|
||||
const past = getPastEvents();
|
||||
result = [...upcoming, ...past];
|
||||
}
|
||||
|
||||
// Apply year filter
|
||||
if (yearFilter !== 'all') {
|
||||
result = result.filter((event) => new Date(event.startDate).getFullYear() === yearFilter);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [statusFilter, yearFilter]);
|
||||
|
||||
// Calculate event counts for filters
|
||||
const eventCounts = useMemo(() => {
|
||||
let allEvents = events;
|
||||
let upcomingEvents = getUpcomingEvents();
|
||||
let pastEvents = getPastEvents();
|
||||
|
||||
// If year filter is applied, filter counts
|
||||
if (yearFilter !== 'all') {
|
||||
allEvents = events.filter((event) => new Date(event.startDate).getFullYear() === yearFilter);
|
||||
upcomingEvents = upcomingEvents.filter(
|
||||
(event) => new Date(event.startDate).getFullYear() === yearFilter,
|
||||
);
|
||||
pastEvents = pastEvents.filter(
|
||||
(event) => new Date(event.startDate).getFullYear() === yearFilter,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
all: allEvents.length,
|
||||
upcoming: upcomingEvents.length,
|
||||
past: pastEvents.length,
|
||||
};
|
||||
}, [yearFilter]);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Events | Promptfoo"
|
||||
description="Meet the Promptfoo team at conferences and events. See live AI security demos, attend workshops, and connect with our security experts."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo Events | AI Security Conferences" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Meet the Promptfoo team at security conferences. See live AI red teaming demos, attend workshops, and connect with our experts."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://promptfoo.dev/events" />
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/og/events-og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/og/events-og.png" />
|
||||
<link rel="canonical" href="https://promptfoo.dev/events" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
{/* Page Header */}
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>Events</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Meet the team, see live demos, and learn how enterprises secure their AI applications.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Featured Event */}
|
||||
{featuredEvent && <FeaturedEvent event={featuredEvent} />}
|
||||
|
||||
{/* Filters */}
|
||||
<EventFilters
|
||||
selectedStatus={statusFilter}
|
||||
selectedYear={yearFilter}
|
||||
availableYears={availableYears}
|
||||
onStatusChange={setStatusFilter}
|
||||
onYearChange={setYearFilter}
|
||||
eventCounts={eventCounts}
|
||||
/>
|
||||
|
||||
{/* Events Grid */}
|
||||
{filteredEvents.length > 0 ? (
|
||||
<div className={styles.grid}>
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
<p>No events found matching your filters.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resetButton}
|
||||
onClick={() => {
|
||||
setStatusFilter('all');
|
||||
setYearFilter('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.cta}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Can't make it to an event?</h2>
|
||||
<p className={styles.ctaDescription}>
|
||||
Book a personalized demo with our team and see how Promptfoo can secure your AI
|
||||
applications.
|
||||
</p>
|
||||
<Link to="/contact" className={styles.ctaButton}>
|
||||
Book a demo
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/* RSA 2025 - Enterprise Security Theme */
|
||||
/* Color Palette: RSA blue (#0066CC), cyber cyan (#06B6D4), professional navy (#1e3a5f) */
|
||||
|
||||
.rsaPage {
|
||||
background: #0a0f1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 15, 26, 0.4) 0%, rgba(10, 15, 26, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(10, 15, 26, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(6, 182, 212, 0.2);
|
||||
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||
color: #67e8f9;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0066cc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(6, 182, 212, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #93c5fd;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(0, 102, 204, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(0, 102, 204, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.recapSection,
|
||||
.showcaseSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.recapSection {
|
||||
background: rgba(6, 182, 212, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Recap Grid */
|
||||
.recapGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recapCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(6, 182, 212, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.recapCard:hover {
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recapCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recapCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Showcase Grid */
|
||||
.showcaseGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.showcaseItem {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(6, 182, 212, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.showcaseNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0066cc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.showcaseItem h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.showcaseItem p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08) 0%, rgba(0, 102, 204, 0.08) 100%);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recapSection,
|
||||
.showcaseSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './rsa-2025.module.css';
|
||||
|
||||
export default function RSA2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at RSA Conference 2025"
|
||||
description="Recap of Promptfoo at RSA Conference 2025. AI red teaming demos and enterprise AI security discussions."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at RSA Conference 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Recap of Promptfoo at RSA Conference 2025. AI red teaming demos and enterprise security."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/rsa-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/events/rsa-2025.jpg" />
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/events/rsa-2025.jpg" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="RSA Conference 2025, AI security, LLM security, enterprise security, San Francisco, red teaming"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/rsa-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.rsaPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/rsa-2025.jpg"
|
||||
alt="RSA Conference 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🛡️</span>
|
||||
RSA Conference 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Enterprise <span className={styles.highlight}>AI Security</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
We showcased AI red teaming capabilities at RSA Conference 2025, connecting with
|
||||
security leaders and demonstrating how enterprises protect their AI applications.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>April 28 - May 1, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Moscone Center, San Francisco</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#recap"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#recap')}
|
||||
>
|
||||
View Recap
|
||||
</a>
|
||||
<Link to="/contact" className={styles.secondaryCta}>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recap Section */}
|
||||
<section id="recap" className={styles.recapSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Event Recap</h2>
|
||||
<p className={styles.sectionSubtitle}>Highlights from RSA Conference 2025</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.recapGrid}>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🎯</div>
|
||||
<h3>Live AI Red Teaming</h3>
|
||||
<p>
|
||||
Demonstrated real-time AI vulnerability testing, showing how enterprises can
|
||||
identify and mitigate LLM security risks before deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🤝</div>
|
||||
<h3>Enterprise Connections</h3>
|
||||
<p>
|
||||
Connected with Fortune 500 security teams, discussing their AI security challenges
|
||||
and how Promptfoo helps protect production AI systems.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>📊</div>
|
||||
<h3>Research Sharing</h3>
|
||||
<p>
|
||||
Shared practical testing patterns: prompt injection regression tests, RAG data
|
||||
exfil probes, and how to operationalize red teaming in CI.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.recapCard}>
|
||||
<div className={styles.cardIcon}>🏆</div>
|
||||
<h3>Expo Floor Presence</h3>
|
||||
<p>
|
||||
Showcased our open-source AI security platform to the global security community at
|
||||
RSA Conference's expo floor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We Showcased */}
|
||||
<section className={styles.showcaseSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What We Showcased</h2>
|
||||
<p className={styles.sectionSubtitle}>Enterprise-grade AI security solutions</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.showcaseGrid}>
|
||||
<div className={styles.showcaseItem}>
|
||||
<div className={styles.showcaseNumber}>01</div>
|
||||
<h3>OWASP Top 10 for LLMs</h3>
|
||||
<p>
|
||||
Complete coverage of the OWASP Top 10 vulnerabilities for Large Language Models,
|
||||
with automated detection and remediation guidance.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.showcaseItem}>
|
||||
<div className={styles.showcaseNumber}>02</div>
|
||||
<h3>Continuous Red Teaming</h3>
|
||||
<p>
|
||||
CI/CD integration for continuous AI security testing, ensuring every deployment is
|
||||
protected against emerging threats.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.showcaseItem}>
|
||||
<div className={styles.showcaseNumber}>03</div>
|
||||
<h3>Audit-Friendly Reporting</h3>
|
||||
<p>
|
||||
Detailed test logs and repeatable security checks, helping enterprises document AI
|
||||
security for governance and compliance reviews.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.showcaseItem}>
|
||||
<div className={styles.showcaseNumber}>04</div>
|
||||
<h3>Custom Attack Vectors</h3>
|
||||
<p>
|
||||
Industry-specific attack simulations tailored to financial services, healthcare,
|
||||
and other regulated industries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>See You at RSA 2026</h2>
|
||||
<p className={styles.ctaText}>
|
||||
We'll be back at RSA Conference 2026. Check out our plans and stay connected for
|
||||
updates on booth location and demos.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/events/rsa-2026" className={styles.primaryCta}>
|
||||
RSA 2026 Details
|
||||
</Link>
|
||||
<Link to="/contact" className={styles.secondaryCta}>
|
||||
Get in Touch
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/* RSA 2026 - Clean Banner Design */
|
||||
/* Color Palette: RSA blue (#0066CC), cyber cyan (#06B6D4), professional navy (#1e3a5f) */
|
||||
|
||||
.rsaPage {
|
||||
background: #0a0f1a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 15, 26, 0.4) 0%, rgba(10, 15, 26, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(10, 15, 26, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(6, 182, 212, 0.2);
|
||||
border: 1px solid rgba(6, 182, 212, 0.4);
|
||||
color: #67e8f9;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0066cc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Countdown Timer */
|
||||
.countdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(6, 182, 212, 0.1);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #67e8f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdownLabel {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #06b6d4;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaBlurb {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(6, 182, 212, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #93c5fd;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(0, 102, 204, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(0, 102, 204, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.highlightsSection,
|
||||
.showcaseSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.highlightsSection {
|
||||
background: rgba(6, 182, 212, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Highlights Grid */
|
||||
.highlightsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.highlightCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(6, 182, 212, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.highlightCard:hover {
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.highlightIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.highlightCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.highlightCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Showcase Grid */
|
||||
.showcaseGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.showcaseItem {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(6, 182, 212, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.showcaseNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0066cc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.showcaseItem h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.showcaseItem p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ctaCard {
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.08) 0%, rgba(0, 102, 204, 0.08) 100%);
|
||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctaCardTitle {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.ctaCardText {
|
||||
font-size: 0.95rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ctaPrimary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(6, 182, 212, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ctaSecondary {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #93c5fd;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(0, 102, 204, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ctaSecondary:hover {
|
||||
background: rgba(0, 102, 204, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(6, 182, 212, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.countdownSeparator {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.countdownItem {
|
||||
min-width: 60px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.countdownNumber {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.countdownSeparator {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta,
|
||||
.ctaPrimary,
|
||||
.ctaSecondary {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctaGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.ctaCard {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.highlightsSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './rsa-2026.module.css';
|
||||
|
||||
function CountdownTimer(): React.ReactElement {
|
||||
const [timeLeft, setTimeLeft] = useState({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const targetDate = new Date('2026-03-23T09:00:00-07:00').getTime();
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date().getTime();
|
||||
const difference = targetDate - now;
|
||||
|
||||
if (difference > 0) {
|
||||
setTimeLeft({
|
||||
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)),
|
||||
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
|
||||
seconds: Math.floor((difference % (1000 * 60)) / 1000),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.countdown}>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.days}</span>
|
||||
<span className={styles.countdownLabel}>Days</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>{timeLeft.hours.toString().padStart(2, '0')}</span>
|
||||
<span className={styles.countdownLabel}>Hours</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Minutes</span>
|
||||
</div>
|
||||
<div className={styles.countdownSeparator}>:</div>
|
||||
<div className={styles.countdownItem}>
|
||||
<span className={styles.countdownNumber}>
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</span>
|
||||
<span className={styles.countdownLabel}>Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RSA2026(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at RSA Conference 2026"
|
||||
description="Meet Promptfoo at RSA Conference 2026. Live AI red teaming demos, security consultations, and enterprise AI security discussions in San Francisco."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at RSA Conference 2026" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Meet Promptfoo at RSA Conference 2026. Live AI red teaming demos and enterprise security."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/rsa-2026" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/events/rsa-2026.jpg" />
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/events/rsa-2026.jpg" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="RSA Conference 2026, AI security, LLM security, enterprise security, San Francisco, red teaming"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/rsa-2026" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.rsaPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/rsa-2026.jpg"
|
||||
alt="RSA Conference 2026"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🚀</span>
|
||||
Coming March 2026
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
RSA Conference <span className={styles.highlight}>2026</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Meet our team for live demos of our AI security platform—red teaming, evals,
|
||||
guardrails, and foundation model security reports for your AI applications.
|
||||
</p>
|
||||
|
||||
<CountdownTimer />
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>March 23-26, 2026</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Moscone Center, San Francisco</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<p className={styles.ctaBlurb}>Book a time to talk to us</p>
|
||||
<Link to="/contact" className={styles.primaryCta}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section id="highlights" className={styles.highlightsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>What to Expect</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Join us for the premier AI security experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.highlightsGrid}>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.highlightIcon}>🎯</div>
|
||||
<h3>AI Red Teaming</h3>
|
||||
<p>
|
||||
See live demos of LLM attack vectors, jailbreak techniques, and how to integrate
|
||||
automated red teaming into your workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.highlightIcon}>🤝</div>
|
||||
<h3>Connect with AI Experts</h3>
|
||||
<p>
|
||||
Meet AI security professionals working on evals, guardrails, and MCP
|
||||
security—learn how they're protecting AI applications in production.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.highlightIcon}>🎬</div>
|
||||
<h3>See it in Action</h3>
|
||||
<p>
|
||||
Quick-fire demos on the latest AI security research, tools, and techniques from
|
||||
the AI security and red teaming experts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaGrid}>
|
||||
<div className={styles.ctaCard}>
|
||||
<h3 className={styles.ctaCardTitle}>Attending RSA?</h3>
|
||||
<p className={styles.ctaCardText}>
|
||||
Book a time to meet with our AI security and red teaming experts at the event.
|
||||
</p>
|
||||
<Link to="/contact" className={styles.primaryCta}>
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.ctaCard}>
|
||||
<h3 className={styles.ctaCardTitle}>Can't make it?</h3>
|
||||
<p className={styles.ctaCardText}>
|
||||
Join our Discord community to connect with our team and our community.
|
||||
</p>
|
||||
<a
|
||||
href="https://discord.com/invite/promptfoo"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.secondaryCta}
|
||||
>
|
||||
Join our Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/* ScaleUp:AI 2025 - Insight Partners / VC Thought Leadership Theme */
|
||||
/* Color Palette: Deep indigo (#4F46E5), slate (#475569), professional navy (#1e293b) */
|
||||
|
||||
.scaleupPage {
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.4) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(15, 23, 42, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(79, 70, 229, 0.2);
|
||||
border: 1px solid rgba(79, 70, 229, 0.4);
|
||||
color: #a5b4fc;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #6366f1 50%, #4f46e5 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(79, 70, 229, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #a5b4fc;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(79, 70, 229, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Quote Section */
|
||||
.quoteSection {
|
||||
padding: 4rem 0;
|
||||
background: rgba(79, 70, 229, 0.02);
|
||||
}
|
||||
|
||||
.quoteContent {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quoteAttribution {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.quoteAuthor {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.quoteRole {
|
||||
font-size: 0.9rem;
|
||||
color: #818cf8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.highlightsSection,
|
||||
.keyPointsSection,
|
||||
.aboutSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.keyPointsSection {
|
||||
background: rgba(79, 70, 229, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Highlights Grid */
|
||||
.highlightsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.highlightCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(79, 70, 229, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.highlightCard:hover {
|
||||
border-color: rgba(79, 70, 229, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.highlightCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.highlightCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Key Points Grid */
|
||||
.keyPointsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.keyPoint {
|
||||
padding: 1.5rem 0;
|
||||
border-left: 2px solid rgba(79, 70, 229, 0.3);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.keyPointNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #818cf8 0%, #4f46e5 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.keyPoint h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.keyPoint p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* About Section */
|
||||
.aboutContent {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08) 0%, rgba(30, 41, 59, 0.1) 100%);
|
||||
border: 1px solid rgba(79, 70, 229, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.aboutText h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.aboutText p {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.aboutLink {
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.aboutLink:hover {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08) 0%, rgba(30, 41, 59, 0.1) 100%);
|
||||
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quote {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.highlightsSection,
|
||||
.keyPointsSection,
|
||||
.aboutSection,
|
||||
.ctaSection,
|
||||
.quoteSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import { SITE_CONSTANTS } from '../../constants';
|
||||
import styles from './scaleup-ai-2025.module.css';
|
||||
|
||||
export default function ScaleUpAI2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at ScaleUp:AI 2025"
|
||||
description="How Promptfoo is restoring trust and security in generative AI. Featured in Insight Partners' ScaleUp:AI 2025 Partner Series."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at ScaleUp:AI 2025 - Insight Partners" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="How Promptfoo is restoring trust and security in generative AI. Ian Webster discusses the future of AI security."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/scaleup-ai-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/scaleup-ai-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/scaleup-ai-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="ScaleUp:AI 2025, Insight Partners, AI security, Promptfoo, Ian Webster, venture capital, AI investment"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/scaleup-ai-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.scaleupPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/scaleup-ai-2025.jpg"
|
||||
alt="ScaleUp:AI 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🚀</span>
|
||||
Insight Partners • ScaleUp:AI 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Restoring <span className={styles.highlight}>Trust in AI</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Featured in Insight Partners' ScaleUp:AI 2025 Partner Series. CEO Ian Webster shares
|
||||
how Promptfoo is defining the standard for enterprise AI security.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>December 3, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Insight Partners</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Ian Webster, CEO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="https://www.insightpartners.com/ideas/promptfoo-scale-up-ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.primaryCta}
|
||||
>
|
||||
Read Full Article
|
||||
</a>
|
||||
<a
|
||||
href="#highlights"
|
||||
className={styles.secondaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#highlights')}
|
||||
>
|
||||
Key Takeaways
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quote Section */}
|
||||
<section className={styles.quoteSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.quoteContent}>
|
||||
<blockquote className={styles.quote}>
|
||||
"AI is underhyped. The software industry looks so different five to ten years from
|
||||
now."
|
||||
</blockquote>
|
||||
<div className={styles.quoteAttribution}>
|
||||
<div className={styles.quoteAuthor}>Ian Webster</div>
|
||||
<div className={styles.quoteRole}>CEO & Co-founder, Promptfoo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Highlights Section */}
|
||||
<section id="highlights" className={styles.highlightsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Key Takeaways</h2>
|
||||
<p className={styles.sectionSubtitle}>Insights from the ScaleUp:AI feature</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.highlightsGrid}>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🔐</div>
|
||||
<h3>Closing the Security Gap</h3>
|
||||
<p>
|
||||
Traditional cybersecurity tools were built for predictable software. LLMs operate
|
||||
in natural language—attackers can manipulate them simply by talking to them.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🤖</div>
|
||||
<h3>Machines Testing Machines</h3>
|
||||
<p>
|
||||
Promptfoo's AI models behave like attackers, red teaming applications through chat
|
||||
interfaces and APIs to uncover vulnerabilities before deployment.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>📈</div>
|
||||
<h3>Rapid Adoption</h3>
|
||||
<p>
|
||||
As featured by Insight Partners: 200,000+ developers and 80+ Fortune 500 companies
|
||||
use Promptfoo, backed by an $18.4M Series A led by Insight.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.highlightCard}>
|
||||
<div className={styles.cardIcon}>🔮</div>
|
||||
<h3>Future of AI Security</h3>
|
||||
<p>
|
||||
As AI systems evolve into autonomous multi-agent systems, observability and
|
||||
continuous security testing become critical for enterprise deployments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Points Section */}
|
||||
<section className={styles.keyPointsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>From the Article</h2>
|
||||
<p className={styles.sectionSubtitle}>Why AI security matters now</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.keyPointsGrid}>
|
||||
<div className={styles.keyPoint}>
|
||||
<div className={styles.keyPointNumber}>01</div>
|
||||
<h3>The Problem</h3>
|
||||
<p>
|
||||
"Old-school defenses aren't reliable because they're designed to read code, not
|
||||
conversation." Traditional security can't protect systems that operate in natural
|
||||
language.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.keyPoint}>
|
||||
<div className={styles.keyPointNumber}>02</div>
|
||||
<h3>The Origin</h3>
|
||||
<p>
|
||||
Ian Webster built the first version of Promptfoo while leading AI products at
|
||||
Discord—shipping to 200 million users taught him "all of the wonderful things, and
|
||||
also the terrible things" that follow.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.keyPoint}>
|
||||
<div className={styles.keyPointNumber}>03</div>
|
||||
<h3>The Urgency</h3>
|
||||
<p>
|
||||
"Global 2000 companies are going to be buying or making decisions in the AI
|
||||
security space in the next 12 months. There's no time to lose."
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.keyPoint}>
|
||||
<div className={styles.keyPointNumber}>04</div>
|
||||
<h3>The Vision</h3>
|
||||
<p>
|
||||
"If we want to see a world where AI really benefits people and companies, we need
|
||||
to put those tools in the hands of developers." Building AI that's intelligent and
|
||||
accountable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About Insight Section */}
|
||||
<section className={styles.aboutSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.aboutContent}>
|
||||
<div className={styles.aboutText}>
|
||||
<h3>About ScaleUp:AI</h3>
|
||||
<p>
|
||||
ScaleUp:AI is Insight Partners' premier content series highlighting insights from
|
||||
the companies and leaders shaping the future of AI. As a global software investor
|
||||
and operational partner, Insight Partners has backed many of the world's most
|
||||
transformative technology companies.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.insightpartners.com/ideas/promptfoo-scale-up-ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.aboutLink}
|
||||
>
|
||||
Read the Full Feature →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Ready to Secure Your AI?</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Join {SITE_CONSTANTS.USER_COUNT_DISPLAY}+ developers and{' '}
|
||||
{SITE_CONSTANTS.FORTUNE_500_COUNT}+ Fortune 500 companies who trust Promptfoo to
|
||||
find and fix vulnerabilities in their AI applications.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/docs/intro" className={styles.primaryCta}>
|
||||
Get Started Free
|
||||
</Link>
|
||||
<Link to="/contact" className={styles.secondaryCta}>
|
||||
Talk to Sales
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
/* SecTor 2025 - Canadian / Toronto Theme */
|
||||
/* Color Palette: Deep crimson (#B22234), white, maple accents, dark slate */
|
||||
|
||||
.sectorPage {
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.4) 0%, rgba(15, 23, 42, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(15, 23, 42, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(178, 34, 52, 0.2);
|
||||
border: 1px solid rgba(178, 34, 52, 0.4);
|
||||
color: #fca5a5;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #b22234 0%, #ef4444 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(178, 34, 52, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #b22234;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #b22234 0%, #991b1b 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(178, 34, 52, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(178, 34, 52, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(178, 34, 52, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.arsenalSection,
|
||||
.whySection,
|
||||
.conversationsSection,
|
||||
.statsSection,
|
||||
.themesSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.arsenalSection {
|
||||
background: rgba(178, 34, 52, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Arsenal Card */
|
||||
.arsenalCard {
|
||||
background: linear-gradient(135deg, rgba(178, 34, 52, 0.1) 0%, rgba(178, 34, 52, 0.05) 100%);
|
||||
border: 1px solid rgba(178, 34, 52, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.arsenalCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #b22234 0%, #ef4444 50%, #b22234 100%);
|
||||
}
|
||||
|
||||
.arsenalBadge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 1rem;
|
||||
background: linear-gradient(135deg, #b22234 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.arsenalTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.arsenalDescription {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.arsenalFeatures {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.arsenalFeature {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.featureIcon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.arsenalFeature h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.arsenalFeature p {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Why Grid */
|
||||
.whyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.whyCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(178, 34, 52, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.whyCard:hover {
|
||||
border-color: rgba(178, 34, 52, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.whyEmoji {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.whyCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.whyCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Conversations Grid */
|
||||
.conversationsSection {
|
||||
background: rgba(178, 34, 52, 0.02);
|
||||
}
|
||||
|
||||
.conversationsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.conversationCard {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(178, 34, 52, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.conversationCard:hover {
|
||||
border-color: rgba(178, 34, 52, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.conversationIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conversationCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.conversationCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
background: linear-gradient(135deg, rgba(178, 34, 52, 0.1) 0%, rgba(178, 34, 52, 0.05) 100%);
|
||||
border-top: 1px solid rgba(178, 34, 52, 0.15);
|
||||
border-bottom: 1px solid rgba(178, 34, 52, 0.15);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Themes Grid */
|
||||
.themesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.themeQuote {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(178, 34, 52, 0.15);
|
||||
border-left: 4px solid #b22234;
|
||||
border-radius: 0 12px 12px 0;
|
||||
padding: 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.themeQuote p {
|
||||
font-size: 1rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 1rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.themeQuote cite {
|
||||
font-size: 0.875rem;
|
||||
color: #b22234;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(178, 34, 52, 0.08) 0%, rgba(30, 41, 59, 0.1) 100%);
|
||||
border: 1px solid rgba(178, 34, 52, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaMaple {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(178, 34, 52, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #b22234;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arsenalCard {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.arsenalTitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arsenalSection,
|
||||
.whySection,
|
||||
.conversationsSection,
|
||||
.statsSection,
|
||||
.themesSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './sector-2025.module.css';
|
||||
|
||||
export default function SecTor2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Promptfoo at SecTor 2025"
|
||||
description="Promptfoo at SecTor 2025 in Toronto. Arsenal demos, security tools showcase, and connecting with Canada's enterprise security community."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at SecTor 2025 - Toronto" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Join Promptfoo at SecTor 2025 in Toronto. Arsenal demos, LLM security tools, and enterprise AI security discussions."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/sector-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/events/sector-2025.jpg" />
|
||||
<meta name="twitter:image" content="https://www.promptfoo.dev/img/events/sector-2025.jpg" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="SecTor 2025, Toronto security conference, Arsenal, AI security tools, Canada cybersecurity, LLM security"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/sector-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.sectorPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img src="/img/events/sector-2025.jpg" alt="SecTor 2025" className={styles.bannerImage} />
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>🍁</span>
|
||||
SecTor 2025 • Toronto
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
North of the Border <span className={styles.highlight}>Security</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Canada's largest IT security conference. We brought open-source AI security tools to
|
||||
the Arsenal and connected with enterprise security teams across the country.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>September 30 - October 2, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Metro Toronto Convention Centre</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Arsenal Listing</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#arsenal"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#arsenal')}
|
||||
>
|
||||
See the Arsenal
|
||||
</a>
|
||||
<Link to="/docs/red-team/" className={styles.secondaryCta}>
|
||||
Try Promptfoo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Arsenal Section */}
|
||||
<section id="arsenal" className={styles.arsenalSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Arsenal Showcase</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Promptfoo was selected for the SecTor Arsenal, showcasing open-source security tools
|
||||
to Canada's enterprise security community.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.arsenalCard}>
|
||||
<div className={styles.arsenalBadge}>SecTor Arsenal 2025</div>
|
||||
<h3 className={styles.arsenalTitle}>Promptfoo: Open-Source LLM Red Teaming</h3>
|
||||
<p className={styles.arsenalDescription}>
|
||||
Live demonstrations of automated AI security testing, including prompt injection
|
||||
detection, jailbreak attempts, and data exfiltration scenarios against real-world
|
||||
LLM applications.
|
||||
</p>
|
||||
<div className={styles.arsenalFeatures}>
|
||||
<div className={styles.arsenalFeature}>
|
||||
<div className={styles.featureIcon}>🎯</div>
|
||||
<div>
|
||||
<h4>Automated Red Teaming</h4>
|
||||
<p>Systematic vulnerability discovery across 15+ attack categories</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.arsenalFeature}>
|
||||
<div className={styles.featureIcon}>🔓</div>
|
||||
<div>
|
||||
<h4>Live Jailbreaking</h4>
|
||||
<p>Real-time demonstrations against production guardrails</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.arsenalFeature}>
|
||||
<div className={styles.featureIcon}>📊</div>
|
||||
<div>
|
||||
<h4>Security Reports</h4>
|
||||
<p>Enterprise-ready vulnerability assessments and remediation guidance</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why SecTor Section */}
|
||||
<section className={styles.whySection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Why SecTor</h2>
|
||||
</div>
|
||||
<div className={styles.whyGrid}>
|
||||
<div className={styles.whyCard}>
|
||||
<div className={styles.whyEmoji}>🇨🇦</div>
|
||||
<h3>Canadian Enterprise</h3>
|
||||
<p>
|
||||
SecTor brings together Canada's largest financial institutions, government
|
||||
agencies, and technology companies under one roof.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.whyCard}>
|
||||
<div className={styles.whyEmoji}>🛡️</div>
|
||||
<h3>Security-First Culture</h3>
|
||||
<p>
|
||||
The Canadian security community takes compliance and data protection seriously.
|
||||
Perfect audience for enterprise AI security tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.whyCard}>
|
||||
<div className={styles.whyEmoji}>🤝</div>
|
||||
<h3>Quality Connections</h3>
|
||||
<p>
|
||||
Smaller than RSA, more focused conversations. We had in-depth discussions about
|
||||
real deployment challenges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Conversations Section */}
|
||||
<section className={styles.conversationsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Key Conversations</h2>
|
||||
</div>
|
||||
<div className={styles.conversationsGrid}>
|
||||
<div className={styles.conversationCard}>
|
||||
<div className={styles.conversationIcon}>🏦</div>
|
||||
<h3>Financial Services</h3>
|
||||
<p>
|
||||
Discussions with major Canadian banks about securing customer-facing AI assistants
|
||||
and internal LLM tools under strict regulatory requirements.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.conversationCard}>
|
||||
<div className={styles.conversationIcon}>🏛️</div>
|
||||
<h3>Government</h3>
|
||||
<p>
|
||||
Federal and provincial teams exploring how to safely deploy AI while meeting
|
||||
Canadian privacy regulations and security standards.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.conversationCard}>
|
||||
<div className={styles.conversationIcon}>🏥</div>
|
||||
<h3>Healthcare</h3>
|
||||
<p>
|
||||
Healthcare organizations interested in AI assistants for patient communication
|
||||
while protecting sensitive health information.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.conversationCard}>
|
||||
<div className={styles.conversationIcon}>⚡</div>
|
||||
<h3>Critical Infrastructure</h3>
|
||||
<p>
|
||||
Energy and utilities companies evaluating AI for operational efficiency while
|
||||
maintaining strict security controls.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>3</div>
|
||||
<div className={styles.statLabel}>Days</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Toronto</div>
|
||||
<div className={styles.statLabel}>Location</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>Oct 1–2</div>
|
||||
<div className={styles.statLabel}>Arsenal Lab</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>MTCC</div>
|
||||
<div className={styles.statLabel}>Venue</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Common Themes */}
|
||||
<section className={styles.themesSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Common Themes</h2>
|
||||
</div>
|
||||
<div className={styles.themesGrid}>
|
||||
<blockquote className={styles.themeQuote}>
|
||||
<p>
|
||||
Teams were looking for AI security tools that fit their compliance requirements,
|
||||
with detailed reports suitable for auditors.
|
||||
</p>
|
||||
<cite>Theme: Financial services</cite>
|
||||
</blockquote>
|
||||
<blockquote className={styles.themeQuote}>
|
||||
<p>
|
||||
Government teams wanted ways to test AI assistants while meeting Canadian privacy
|
||||
regulations and GRC requirements.
|
||||
</p>
|
||||
<cite>Theme: Public sector</cite>
|
||||
</blockquote>
|
||||
<blockquote className={styles.themeQuote}>
|
||||
<p>
|
||||
Open source was important—being able to audit the testing methodology was a key
|
||||
requirement for many teams.
|
||||
</p>
|
||||
<cite>Theme: Enterprise security</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<div className={styles.ctaMaple}>🍁</div>
|
||||
<h2 className={styles.ctaTitle}>Merci • Thank You</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Thanks to the SecTor team and everyone who stopped by the Arsenal. We're excited to
|
||||
continue working with the Canadian security community.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/docs/red-team/quickstart/" className={styles.primaryCta}>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link to="https://discord.gg/promptfoo" className={styles.secondaryCta}>
|
||||
Join Discord
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/* Telecom Talks 2025 - Network/Signal Theme */
|
||||
/* Color Palette: Electric cyan (#00D4FF), deep navy (#0A1628), signal green (#00FF88) */
|
||||
|
||||
.telecomPage {
|
||||
background: #0a1628;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
/* Hero Banner */
|
||||
.heroBanner {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bannerImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.bannerOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 22, 40, 0.4) 0%, rgba(10, 22, 40, 0.7) 100%);
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 3rem;
|
||||
background: linear-gradient(0deg, rgba(10, 22, 40, 0.95) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
color: #67e8f9;
|
||||
border-radius: 100px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.badgeIcon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: clamp(2rem, 6vw, 3.5rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
color: #f1f5f9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00ff88 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Hero Content Section */
|
||||
.heroContent {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00b4d8 100%);
|
||||
color: #0a1628;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primaryCta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0, 212, 255, 0.4);
|
||||
color: #0a1628;
|
||||
}
|
||||
|
||||
.secondaryCta {
|
||||
padding: 0.875rem 2rem;
|
||||
background: transparent;
|
||||
color: #67e8f9;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondaryCta:hover {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Terminal Section */
|
||||
.terminalSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
background: rgba(10, 22, 40, 0.9);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.terminalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.terminalDots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminalDots span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.terminalDots span:first-child {
|
||||
background: #ff5f57;
|
||||
}
|
||||
|
||||
.terminalDots span:nth-child(2) {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
.terminalDots span:last-child {
|
||||
background: #28ca41;
|
||||
}
|
||||
|
||||
.terminalTitle {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.terminalBody {
|
||||
padding: 1.5rem;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.logLine {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logSuccess {
|
||||
color: #00ff88;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.logInfo {
|
||||
color: #00d4ff;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.logWarning {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.logText {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.sessionSection,
|
||||
.challengesSection,
|
||||
.discussionsSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.sessionSection {
|
||||
background: rgba(0, 212, 255, 0.02);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Session Card */
|
||||
.sessionCard {
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 255, 136, 0.05) 100%);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sessionCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #00d4ff 0%, #00ff88 50%, #00d4ff 100%);
|
||||
}
|
||||
|
||||
.sessionMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sessionBadge {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 1rem;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00ff88 100%);
|
||||
color: #0a1628;
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.sessionTime {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.sessionTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sessionDescription {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.sessionSpeakers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.speakerAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00ff88 100%);
|
||||
color: #0a1628;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.speakerName {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.speakerCompany {
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.speakerDivider {
|
||||
font-size: 1.5rem;
|
||||
color: #00d4ff;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Challenges Grid */
|
||||
.challengesSection {
|
||||
background: rgba(0, 212, 255, 0.02);
|
||||
}
|
||||
|
||||
.challengesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.challengeCard {
|
||||
background: rgba(10, 22, 40, 0.6);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.challengeCard:hover {
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.challengeIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.challengeCard h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.challengeCard p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Discussions List */
|
||||
.discussionsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.discussionItem {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.discussionItem:hover {
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.discussionNumber {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #00d4ff;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discussionContent h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.discussionContent p {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.statsSection {
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 255, 136, 0.05) 100%);
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.15);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.statNumber {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #00ff88 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* CTA Section */
|
||||
.ctaContent {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.08) 0%, rgba(0, 255, 136, 0.05) 100%);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.ctaTitle {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ctaText {
|
||||
font-size: 1rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Footer Navigation */
|
||||
.footerNav {
|
||||
padding: 2rem 0 3rem;
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.heroBanner {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.bannerContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.heroSubtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.eventDetails {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heroCtas {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryCta,
|
||||
.secondaryCta {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminalBody {
|
||||
padding: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.logLine {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sessionCard {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.sessionTitle {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sessionSpeakers {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.speakerDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.discussionItem {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.ctaContent {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.ctaButtons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminalSection,
|
||||
.sessionSection,
|
||||
.challengesSection,
|
||||
.discussionsSection,
|
||||
.statsSection,
|
||||
.ctaSection {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import { useForcedTheme } from '@site/src/hooks/useForcedTheme';
|
||||
import Layout from '@theme/Layout';
|
||||
import styles from './telecom-talks-2025.module.css';
|
||||
|
||||
export default function TelecomTalks2025(): React.ReactElement {
|
||||
useForcedTheme('dark');
|
||||
|
||||
const handleSmoothScroll = (e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(targetId);
|
||||
if (element) {
|
||||
const offset = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="Telecom Talks 2025"
|
||||
description="Promptfoo at Telecom Talks 2025. Ian Webster on stage with Swisscom Outpost discussing AI security in telecommunications."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:title" content="Promptfoo at Telecom Talks 2025" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="AI security for telecommunications. Ian Webster joined Swisscom Outpost on stage at Telecom Talks 2025 in Menlo Park."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.promptfoo.dev/events/telecom-talks-2025" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://www.promptfoo.dev/img/events/telecom-talks-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://www.promptfoo.dev/img/events/telecom-talks-2025.jpg"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Telecom Talks 2025, telecommunications security, AI security, LLM security, Swisscom, 5G security"
|
||||
/>
|
||||
<link rel="canonical" href="https://www.promptfoo.dev/events/telecom-talks-2025" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.telecomPage}>
|
||||
{/* Hero Banner */}
|
||||
<section className={styles.heroBanner}>
|
||||
<img
|
||||
src="/img/events/telecom-talks-2025.jpg"
|
||||
alt="Telecom Talks 2025"
|
||||
className={styles.bannerImage}
|
||||
/>
|
||||
<div className={styles.bannerOverlay} />
|
||||
<div className={styles.bannerContent}>
|
||||
<div className={styles.badge}>
|
||||
<span className={styles.badgeIcon}>📡</span>
|
||||
Telecom Talks 2025
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Securing the <span className={styles.highlight}>Signal</span>
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Content */}
|
||||
<section className={styles.heroContent}>
|
||||
<div className={styles.container}>
|
||||
<p className={styles.heroSubtitle}>
|
||||
AI security meets telecom. Ian Webster joined Swisscom Outpost on stage to discuss
|
||||
practical GenAI security challenges for real deployments.
|
||||
</p>
|
||||
|
||||
<div className={styles.eventDetails}>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>April 9, 2025</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>SRI International, Menlo Park</span>
|
||||
</div>
|
||||
<div className={styles.detail}>
|
||||
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>On Stage with Swisscom Outpost</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCtas}>
|
||||
<a
|
||||
href="#session"
|
||||
className={styles.primaryCta}
|
||||
onClick={(e) => handleSmoothScroll(e, '#session')}
|
||||
>
|
||||
View Session
|
||||
</a>
|
||||
<Link to="/docs/red-team/" className={styles.secondaryCta}>
|
||||
Explore Red Teaming
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Terminal Event Log */}
|
||||
<section className={styles.terminalSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.terminal}>
|
||||
<div className={styles.terminalHeader}>
|
||||
<div className={styles.terminalDots}>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<span className={styles.terminalTitle}>event.log</span>
|
||||
</div>
|
||||
<div className={styles.terminalBody}>
|
||||
<div className={styles.logLine}>
|
||||
<span className={styles.timestamp}>[CONNECTED]</span>
|
||||
<span className={styles.logSuccess}>LOCATION</span>
|
||||
<span className={styles.logText}>Telecom Talks 2025 • SRI International</span>
|
||||
</div>
|
||||
<div className={styles.logLine}>
|
||||
<span className={styles.timestamp}>[SESSION]</span>
|
||||
<span className={styles.logInfo}>PANEL</span>
|
||||
<span className={styles.logText}>
|
||||
GenAI security challenges with Swisscom Outpost
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.logLine}>
|
||||
<span className={styles.timestamp}>[DEMO]</span>
|
||||
<span className={styles.logWarning}>LIVE</span>
|
||||
<span className={styles.logText}>LLM red teaming concepts and failure modes</span>
|
||||
</div>
|
||||
<div className={styles.logLine}>
|
||||
<span className={styles.timestamp}>[NETWORK]</span>
|
||||
<span className={styles.logSuccess}>CONNECT</span>
|
||||
<span className={styles.logText}>
|
||||
Conversations with telecom security leaders
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Session Section */}
|
||||
<section id="session" className={styles.sessionSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>The Session</h2>
|
||||
</div>
|
||||
<div className={styles.sessionCard}>
|
||||
<div className={styles.sessionMeta}>
|
||||
<span className={styles.sessionBadge}>Joint Presentation</span>
|
||||
<span className={styles.sessionTime}>
|
||||
April 9, 2025 • SRI International, Menlo Park
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={styles.sessionTitle}>AI Security in Telecommunications</h3>
|
||||
<p className={styles.sessionDescription}>
|
||||
Ian Webster joined Swisscom's security team on stage to discuss the unique
|
||||
challenges of securing AI systems in telecommunications infrastructure. From
|
||||
customer service chatbots to network operations assistants, telecom companies are
|
||||
deploying LLMs at scale—and facing new security challenges.
|
||||
</p>
|
||||
<div className={styles.sessionSpeakers}>
|
||||
<div className={styles.speaker}>
|
||||
<div className={styles.speakerAvatar}>IW</div>
|
||||
<div>
|
||||
<div className={styles.speakerName}>Ian Webster</div>
|
||||
<div className={styles.speakerCompany}>Promptfoo</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.speakerDivider}>+</div>
|
||||
<div className={styles.speaker}>
|
||||
<div className={styles.speakerAvatar}>SC</div>
|
||||
<div>
|
||||
<div className={styles.speakerName}>Swisscom Outpost</div>
|
||||
<div className={styles.speakerCompany}>Security Team</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Telecom Challenges Section */}
|
||||
<section className={styles.challengesSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Telecom Security Challenges</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Why AI security is critical for the telecommunications industry.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.challengesGrid}>
|
||||
<div className={styles.challengeCard}>
|
||||
<div className={styles.challengeIcon}>🌐</div>
|
||||
<h3>5G & Edge Computing</h3>
|
||||
<p>
|
||||
AI at the network edge introduces new attack surfaces. From base stations to
|
||||
customer premises equipment, LLMs are everywhere.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.challengeCard}>
|
||||
<div className={styles.challengeIcon}>🔌</div>
|
||||
<h3>API Exposure</h3>
|
||||
<p>
|
||||
Telecom APIs increasingly use AI for routing, fraud detection, and customer
|
||||
service. Each endpoint is a potential vulnerability.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.challengeCard}>
|
||||
<div className={styles.challengeIcon}>📱</div>
|
||||
<h3>Customer Data</h3>
|
||||
<p>
|
||||
Call records, location data, billing information. AI systems processing this data
|
||||
need rigorous security testing.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.challengeCard}>
|
||||
<div className={styles.challengeIcon}>⚡</div>
|
||||
<h3>Critical Infrastructure</h3>
|
||||
<p>
|
||||
Networks that power emergency services, hospitals, and financial systems. The
|
||||
stakes for AI security couldn't be higher.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key Discussions */}
|
||||
<section className={styles.discussionsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>Key Discussions</h2>
|
||||
</div>
|
||||
<div className={styles.discussionsList}>
|
||||
<div className={styles.discussionItem}>
|
||||
<div className={styles.discussionNumber}>01</div>
|
||||
<div className={styles.discussionContent}>
|
||||
<h3>Carrier-Grade LLM Security</h3>
|
||||
<p>
|
||||
How major telecom providers are approaching AI security at scale, including
|
||||
lessons learned from early deployments and best practices emerging across the
|
||||
industry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.discussionItem}>
|
||||
<div className={styles.discussionNumber}>02</div>
|
||||
<div className={styles.discussionContent}>
|
||||
<h3>Network API Protection</h3>
|
||||
<p>
|
||||
Strategies for securing AI-powered network APIs against prompt injection, data
|
||||
exfiltration, and unauthorized access attempts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.discussionItem}>
|
||||
<div className={styles.discussionNumber}>03</div>
|
||||
<div className={styles.discussionContent}>
|
||||
<h3>Regulatory Compliance</h3>
|
||||
<p>
|
||||
Navigating GDPR, telecommunications regulations, and emerging AI governance
|
||||
frameworks while deploying innovative AI solutions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Signal Stats */}
|
||||
<section className={styles.statsSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>1</div>
|
||||
<div className={styles.statLabel}>Day</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>200+</div>
|
||||
<div className={styles.statLabel}>Attendees</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>0ms</div>
|
||||
<div className={styles.statLabel}>Latency to Security</div>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statNumber}>5G</div>
|
||||
<div className={styles.statLabel}>Speed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={styles.ctaSection}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.ctaContent}>
|
||||
<h2 className={styles.ctaTitle}>Establish Connection</h2>
|
||||
<p className={styles.ctaText}>
|
||||
Ready to secure your telecom AI deployments? Start testing with Promptfoo's
|
||||
open-source red teaming framework.
|
||||
</p>
|
||||
<div className={styles.ctaButtons}>
|
||||
<Link to="/docs/red-team/quickstart/" className={styles.primaryCta}>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link to="https://github.com/promptfoo/promptfoo" className={styles.secondaryCta}>
|
||||
View Source
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<section className={styles.footerNav}>
|
||||
<div className={styles.container}>
|
||||
<Link to="/events" className={styles.backLink}>
|
||||
<svg
|
||||
className={styles.backIcon}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to All Events
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useColorMode } from '@docusaurus/theme-common';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Layout from '@theme/Layout';
|
||||
|
||||
const FeedbackPageContent = () => {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const theme = React.useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: {
|
||||
mode: colorMode === 'dark' ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: '#0066cc',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[colorMode],
|
||||
);
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '1100px',
|
||||
border: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
||||
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
We'd love your feedback
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: '600px', mx: 'auto', lineHeight: 1.6 }}
|
||||
>
|
||||
Help us improve Promptfoo by sharing your thoughts and suggestions
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
boxShadow:
|
||||
colorMode === 'dark'
|
||||
? '0 4px 20px rgba(0, 0, 0, 0.3)'
|
||||
: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="https://docs.google.com/forms/d/e/1FAIpQLScAnqlqX-ep-aOn6umjXXDVafc1sLTOEd5W6rMAPKllLk0CIA/viewform?embedded=1"
|
||||
title="PromptFoo Feedback Form"
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
style={iframeStyle}
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
>
|
||||
Loading…
|
||||
</iframe>
|
||||
</Box>
|
||||
</Container>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Feedback = () => {
|
||||
return (
|
||||
<Layout title="Feedback" description="Share your feedback and help us improve Promptfoo">
|
||||
<FeedbackPageContent />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Feedback;
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
|
||||
import Head from '@docusaurus/Head';
|
||||
import Link from '@docusaurus/Link';
|
||||
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
|
||||
import SecurityUpdateGoodIcon from '@mui/icons-material/SecurityUpdateGood';
|
||||
import ShieldIcon from '@mui/icons-material/Shield';
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
|
||||
import Layout from '@theme/Layout';
|
||||
import clsx from 'clsx';
|
||||
import LogoContainer from '../components/LogoContainer';
|
||||
import styles from './landing-page.module.css';
|
||||
|
||||
function HeroSection() {
|
||||
return (
|
||||
<section className={styles.heroSection}>
|
||||
<div className="container">
|
||||
<img src="/img/guardrails-framed.png" alt="AI Guardrails" className={styles.heroImage} />
|
||||
<div className={styles.logoSection}>
|
||||
Promptfoo is trusted by teams at...
|
||||
<LogoContainer className={styles.heroLogos} noBackground noBorder />
|
||||
</div>
|
||||
{/*
|
||||
<h2>Guardrails that evolve with emerging threats</h2>
|
||||
<p>
|
||||
Break free from static guardrails with our adaptive system that learns from red team
|
||||
findings and real-world attacks.
|
||||
</p>
|
||||
*/}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GuardrailsHeader() {
|
||||
return (
|
||||
<header className={clsx('hero', styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<div className={styles.heroContent}>
|
||||
<h1 className={styles.heroTitle}>AI Guardrails that learn & adapt</h1>
|
||||
<p className={styles.heroSubtitle}>
|
||||
Self-improving protection powered by continuous red teaming feedback
|
||||
</p>
|
||||
<div className={styles.heroButtons}>
|
||||
<Link
|
||||
className={clsx('button button--primary button--lg', styles.buttonPrimary)}
|
||||
to="/contact/"
|
||||
>
|
||||
Request a Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HeroSection />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsSection() {
|
||||
return (
|
||||
<section className={styles.benefitsSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>THE ADAPTIVE ADVANTAGE</div>
|
||||
<h2 className={styles.sectionTitle}>Why choose adaptive guardrails?</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Move beyond static rules to guardrails that evolve with emerging threats
|
||||
</p>
|
||||
<div className={styles.benefitsList}>
|
||||
<div className={styles.benefitItem}>
|
||||
<ShieldIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Self-improving protection</h3>
|
||||
<p>
|
||||
Unlike static guardrails, our system continuously learns from red team findings,
|
||||
becoming more effective against new and evolving threats over time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<SecurityUpdateGoodIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Validate any guardrail system</h3>
|
||||
<p>
|
||||
Use our red teaming capabilities to test and improve third-party guardrails you
|
||||
already have in place, providing an independent verification layer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<AccountBalanceIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Fast, flexible deployment</h3>
|
||||
<p>
|
||||
Implement in minutes with minimal code changes, on cloud or on-premises, supporting
|
||||
all major LLM providers and custom models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.benefitItem}>
|
||||
<VerifiedUserIcon className={styles.benefitIcon} />
|
||||
<div className={styles.benefitContent}>
|
||||
<h3>Data-driven improvement</h3>
|
||||
<p>
|
||||
Our system uses actual attack data to refine defenses, creating a feedback loop that
|
||||
makes your guardrails stronger with every attempted breach.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionOrientedSection() {
|
||||
return (
|
||||
<section className={styles.actionOrientedSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>ADAPTIVE LEARNING</div>
|
||||
<h2 className={styles.sectionTitle}>Guardrails that get smarter over time</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Our unique feedback loop between red teaming and guardrails creates a continuously
|
||||
improving defense system.
|
||||
</p>
|
||||
<div className={styles.screenshotPlaceholder}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/img/guardrails-table.png"
|
||||
alt="Adaptive guardrails implementation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinuousMonitoringSection() {
|
||||
return (
|
||||
<section className={styles.actionOrientedSection}>
|
||||
<div className="container">
|
||||
<div className={styles.sectionEyebrow}>INDEPENDENT VALIDATION</div>
|
||||
<h2 className={styles.sectionTitle}>Third-Party Guardrail Validation</h2>
|
||||
<p className={styles.sectionSubtitle}>
|
||||
Already using another guardrail system? Our platform can validate and improve any
|
||||
guardrail solution.
|
||||
</p>
|
||||
<div className={styles.screenshotPlaceholder}>
|
||||
<img
|
||||
loading="lazy"
|
||||
src="/img/continuous-monitoring.png"
|
||||
srcSet="/img/continuous-monitoring.png 1x, /img/continuous-monitoring@2x.png 2x"
|
||||
alt="Third-party guardrail validation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CallToActionSection() {
|
||||
return (
|
||||
<section className={styles.finalCTA}>
|
||||
<div className="container">
|
||||
<h2 className={styles.finalCTATitle}>Upgrade to guardrails that learn and adapt</h2>
|
||||
<p className={styles.finalCTASubtitle}>
|
||||
Join leading enterprises using adaptive AI guardrails to stay ahead of emerging threats.
|
||||
</p>
|
||||
<div className={styles.finalCTAButtons}>
|
||||
<Link className="button button--primary button--lg" to="/contact/">
|
||||
Request Demo
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.finalCTATrust}>
|
||||
<p>✓ Self-improving • ✓ Third-party validation • ✓ Cloud or on-premise</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Guardrails(): React.ReactElement {
|
||||
return (
|
||||
<Layout
|
||||
title="AI Guardrails"
|
||||
description="Comprehensive AI guardrails to ensure safe, compliant, and brand-aligned outputs. Protect against harmful content, PII exposure, prompt injections, and regulatory violations."
|
||||
>
|
||||
<Head>
|
||||
<meta property="og:image" content="https://www.promptfoo.dev/img/meta/guardrails.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</Head>
|
||||
<div className={styles.pageContainer}>
|
||||
<GuardrailsHeader />
|
||||
<main className={styles.mainContent}>
|
||||
<ActionOrientedSection />
|
||||
<ContinuousMonitoringSection />
|
||||
<BenefitsSection />
|
||||
<CallToActionSection />
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user