diff --git a/.github/instructions/general.instructions.md b/.github/instructions/general.instructions.md index 61e3a5096..ba63302bd 100644 --- a/.github/instructions/general.instructions.md +++ b/.github/instructions/general.instructions.md @@ -35,7 +35,9 @@ applyTo: "**" - JavaScript loading warnings from happy-dom are silenced in vitest.setup.ts for clean test output. - A working example test using the Container API is available at /home/kevin/Repos/Webstack Builders/Corporate Website/astro.webstackbuilders.com/src/components/Test/container.spec.ts - **NEVER hard-code content slugs in e2e tests** (e.g., `/articles/typescript-best-practices`, `/services/web-development`). Content can be deleted or renamed. Always dynamically fetch the first available item from listing pages (articles, services, case-studies, etc.) and navigate to it. This prevents test breakage when content changes. -- **Playwright E2E Tests**: Set `DEBUG=1` environment variable to prevent the dev server from being launched by the Playwright test runner. This is useful when you want to run tests against an already running dev server. +- **Playwright E2E Tests**: ALWAYS run with `DEBUG=1` environment variable (e.g., `DEBUG=1 npx playwright test`). This prevents the Playwright test runner from launching its own dev server. The user maintains a running dev server for development. +- **NEVER run the full e2e test suite** unless explicitly requested by the user. The full suite is very resource intensive and takes over 10 minutes to run. Only run specific e2e test files when verification is needed (e.g., `DEBUG=1 npx playwright test test/e2e/specific-file.spec.ts`). +- **NEVER start a dev server yourself**. The user runs their own dev server for development. When you need a dev server running, notify the user instead of starting one. # Personality - Do not apologize diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 7fe8c6bbd..01b33c7e9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -3,7 +3,6 @@ name: CI - Build & Test on: push: - branches: [main] pull_request: branches: [main] @@ -25,6 +24,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Run TypeScript check + run: npm run check + - name: Run lint run: npm run lint diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml new file mode 100644 index 000000000..5a0901b1c --- /dev/null +++ b/.github/workflows/type-check.yml @@ -0,0 +1,31 @@ +# Fast code quality checks (TypeScript + Linting) +name: Code Quality Check + +on: + push: + pull_request: + branches: [main] + +jobs: + quality-check: + name: Code Quality Check + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript check + run: npm run check + + - name: Run linting + run: npm run lint \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 3364b7f7e..86819d136 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,25 @@ #!/bin/sh +# Load nvm if available and use appropriate node version +if [ -f "$HOME/.nvm/nvm.sh" ]; then + . "$HOME/.nvm/nvm.sh" + # Try different nvm use strategies in order of preference + if nvm use 2>/dev/null; then + echo "Using current nvm node version" + elif nvm use node 2>/dev/null; then + echo "Using latest nvm node version" + elif nvm use --lts 2>/dev/null; then + echo "Using LTS node version" + else + echo "Using system node version" + fi +fi + +# Run TypeScript check (fast feedback on type errors) +npm run check + # Run unit tests (fast feedback) -nvm use default && npm run test:unit +npm run test:unit # Verify branch naming convention .husky/scripts/check-branch-name.sh diff --git a/.vscode/settings.json b/.vscode/settings.json index 891d63a94..b20da8a26 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "Flink", "FNAME", "fosstodon", + "glidejs", "GSAP", "hocho", "Hudi", @@ -67,9 +68,12 @@ "Qualys", "repost", "reposts", + "RGAA", "Ryuk", + "samp", "SCORM", "shiki", + "shikijs", "shipit", "SIEM", "signup", diff --git a/README.md b/README.md index 196e85762..400861a8a 100644 --- a/README.md +++ b/README.md @@ -143,58 +143,7 @@ To add a new theme (e.g., a holiday theme), follow these steps: #### 1. Add CSS Variables in `src/styles/themes.css` -Add a new CSS rule with your theme's custom properties: - -```css -/* Holiday Theme Example */ -[data-theme="holiday"] { - /* Background Colors */ - --color-bg: #0f172a; - --color-bg-offset: #1e293b; - - /* Text Colors */ - --color-text: #f1f5f9; - --color-text-offset: #cbd5e1; - - /* Primary Brand Colors */ - --color-primary: #dc2626; - --color-primary-offset: #991b1b; - --color-primary-bg: #7f1d1d; - --color-primary-bg-hover: #991b1b; - --color-primary-hover: #b91c1c; - - /* Secondary Colors */ - --color-secondary: #16a34a; - --color-secondary-offset: #15803d; - --color-secondary-bg: #052e16; - - /* Status Colors */ - --color-success: #16a34a; - --color-success-offset: #22c55e; - --color-success-bg: #052e16; - - --color-info: #0891b2; - --color-info-bg: #164e63; - - --color-warning: #a16207; - --color-warning-offset: #ca8a04; - --color-warning-bg: #451a03; - - --color-danger: #dc2626; - --color-danger-bg: #7f1d1d; - - /* Special Colors */ - --color-twitter: #1da1f2; - --color-modal-background: #0f172a; - - /* Accent Colors */ - --color-accent: #fbbf24; - --color-accent-bg: #451a03; - - /* Syntax Highlighting */ - --shiki-theme: 'github-dark'; -} -``` +Add a new CSS rule with the theme's custom colors following the pattern of existing themes in this file. #### 2. Register Theme in `src/lib/themes.ts` diff --git a/THEME_SYSTEM_WIP.md b/THEME_SYSTEM_WIP.md new file mode 100644 index 000000000..855df2f14 --- /dev/null +++ b/THEME_SYSTEM_WIP.md @@ -0,0 +1,127 @@ +# Theme System - Work in Progress + +## Current Status + +### What's Working +- ✅ Theme picker tests: 9/10 @ready (63 tests passing across 7 browsers) +- ✅ Manual theme selection and persistence works correctly +- ✅ Theme switching via theme picker UI works +- ✅ View Transitions correctly maintain theme across page navigations +- ✅ CSS architecture refactored: `@theme inline` uses `var()` references instead of hard-coded values + +### What's Broken +- ❌ **System preference (prefers-color-scheme) not respected on first visit in real browser** + - Test passes but real usage fails + - Opening site in new incognito window with dark mode preference shows light theme + - Google.com correctly shows dark, but our site doesn't + +- ❌ **Theme picker buttons should show their own theme's colors** + - Dark theme button should use `--dark-color-*` variables + - Default theme button should use `--light-color-*` variables + - Currently all buttons use light theme colors (partially fixed but needs completion) + +## Root Cause Analysis + +### System Preference Issue + +The problem is a **race condition** between: +1. HEAD inline script (synchronous) - correctly sets `data-theme="dark"` based on `prefers-color-scheme` +2. `persistentAtom` restore (asynchronous) - overwrites theme back to 'default' + +**Sequence of events:** +``` +1. HEAD script runs → checks localStorage (empty) → checks prefersDark=true → sets data-theme="dark" ✓ +2. persistentAtom initializes with default value 'default' +3. Our init code runs with setTimeout(100ms) +4. persistentAtom's restore() completes (async) → fires .listen() → overwrites to 'default' ✗ +``` + +**Current fix attempt:** +- Using `isInitialized` flag to prevent `.listen()` from applying themes until init completes +- Using `setTimeout(100ms)` to delay init until after `persistentAtom.restore()` completes +- **Problem:** The timing is unreliable - 100ms might not be enough on slower devices + +**Test vs Reality:** +- Playwright test passes because it's using `emulateMedia({ colorScheme: 'dark' })` +- Real browser behavior is different - the race condition manifests differently +- Test needs to be improved to catch this real-world bug + +## Files Modified + +### Theme Initialization +- `src/components/Scripts/state/store/themes.ts` (lines 127-180) + - Changed from `.subscribe()` to `.listen()` to avoid immediate firing + - Added `isInitialized` flag to gate theme applications + - Added `setTimeout(100)` to wait for `persistentAtom.restore()` + - **HAS DEBUG LOGGING** - needs to be removed before commit + +### Theme Picker UI +- `src/components/ThemePicker/Themes.astro` (lines 97-120) + - **NOT YET FIXED** - still needs to map theme.id to color prefix + - Should use `--${colorPrefix}-color-*` variables per button + +### HEAD Script +- `src/components/Head/index.astro` (lines 56-63) + - Correctly checks `prefers-color-scheme` and sets `data-theme` + - Logic: stored theme (if not 'default') > system preference > 'default' + +### CSS Architecture +- `src/styles/themes.css` + - ✅ Lines 65-105: `@theme inline` refactored to use `var()` references + - ✅ All theme-specific colors defined at `:root` level + - Has `--light-color-*`, `--dark-color-*`, and base `--color-*` variables + +## Next Steps + +### High Priority +1. **Fix system preference detection** + - Option A: Find more reliable way to detect when `persistentAtom.restore()` completes + - Option B: Use `MutationObserver` to watch for theme changes from restore + - Option C: Initialize theme BEFORE importing `persistentAtom` + - Option D: Use regular `atom` for store, manually sync to localStorage after restore completes + - Option E: Don't rely on setTimeout - use `requestIdleCallback` or similar + +2. **Fix theme picker button colors** + - Complete the Themes.astro fix to show each theme's own colors + - Map `theme.id` to color variable prefix: 'default' → 'light', 'dark' → 'dark' + - Update color swatches to use theme-specific variables + +3. **Remove debug logging** + - `src/components/Scripts/state/store/themes.ts` has console.log statements + - Clean these up before final commit + +### Test Improvements +4. **Make test match real browser behavior** + - Current test uses `emulateMedia()` which might not trigger same race condition + - Consider testing with actual localStorage clearing and page reload + - Add test that validates theme immediately on page load (before JS runs) + +## Technical Constraints + +- **MUST use `persistentAtom`** - required for View Transitions to maintain theme across navigations +- **CANNOT use regular `atom`** - will lose persistence across page navigations +- HEAD script must run synchronously to prevent FOUC (Flash of Unstyled Content) +- Theme must be applied before page renders (critical for UX) + +## Code Locations + +- Theme store: `src/components/Scripts/state/store/themes.ts` +- Theme picker UI: `src/components/ThemePicker/Themes.astro` +- Theme picker element: `src/components/ThemePicker/theme-picker-element.ts` +- HEAD script: `src/components/Head/index.astro` (lines 56-63) +- CSS themes: `src/styles/themes.css` +- E2E tests: `test/e2e/specs/04-components/theme-picker.spec.ts` (line 134 is failing test) + +## Questions to Answer + +1. When exactly does `persistentAtom.restore()` complete? +2. Is there an event or promise we can wait for? +3. Should we implement our own localStorage persistence instead of using `persistentAtom`? +4. Can we leverage the `@media (prefers-color-scheme: dark)` CSS to avoid needing JS for system preference? + +## Useful Context + +- The `@media (prefers-color-scheme: dark)` CSS rule at lines 337-373 in themes.css correctly applies dark theme +- This CSS works WITHOUT JavaScript +- The issue is the JS is overriding this CSS by setting `data-theme="default"` +- Maybe we should NOT set `data-theme` at all when using system preference? diff --git a/TODO.md b/TODO.md index a3d315998..5382fc350 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,75 @@ # TODO -From the error output, the article page has: - -

- the actual article title (correct) -

- from markdown content (wrong!) -

No islands detected.

- from some debug/dev tool -

Audit

- from some debug/dev tool -

No accessibility or performance issues detected.

- from debug/dev tool -

Settings

- from debug/dev tool +Files with Skipped Tests: + +social-shares.spec.ts - 12 @wip +gdpr-consent.spec.ts - 10 @wip + +Blocked Categories (44 tests): + +Visual regression testing (18) - Needs Percy/Chromatic +PWA functionality (12) - Service workers not implemented +Lighthouse audits (6) - Integration pending +Newsletter double opt-in (6) - Email testing infrastructure +Axe accessibility (2) - axe-core integration + +\[color:var\(--color-(.*?)\)\] + +## Axe tags + +cat.aria: Rules related to Accessible Rich Internet Applications (ARIA) attributes and roles. +cat.color: Rules related to color contrast and meaning conveyed by color. +cat.controls: Rules for interactive controls, such as form elements and links. +cat.forms: Rules specifically for forms, form fields, and their labels. +cat.keyboard: Rules related to keyboard operability. +cat.links: Rules for links, including their names and destinations. +cat.name-role-value: Rules that check if an element has a name, role, and value that can be correctly interpreted by assistive technologies. +cat.semantics: Rules related to the semantic structure of a document, such as headings and landmarks. +cat.sensory-and-visual-cues: Rules that deal with information conveyed by sensory or visual characteristics. +cat.structure: Rules related to the document's overall structure, like the proper nesting of elements. +cat.tables: Rules for data tables, including headers and associations. +cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. + +## Social Media Preview Cards + +Looking at the social-card endpoint implementation, it's designed to work with third-party screenshot services, not the social networks themselves. + +Here's how it works: + +The Two Formats +HTML format (format=html or default): Returns a full HTML page with inline CSS styled as a 1200x630px card - the standard Open Graph image dimensions. + +OG format (format=og): Returns JSON with Open Graph meta tags, where the og:image URL points back to the HTML version of the card. + +How Social Networks Actually Work +Social networks like Twitter, Facebook, LinkedIn, etc. don't screenshot HTML pages. They expect: + +- Direct image URLs (PNG, JPEG, etc.) +- Standard dimensions (1200x630px for most platforms) + +The Intended Workflow + +This endpoint is designed to integrate with screenshot services like: + +- Puppeteer or Playwright - Run your own screenshot service +- Vercel OG Image Generation - Vercel's built-in service +- Cloudinary - Can fetch and screenshot URLs +- ScreenshotOne or ApiFlash - Dedicated screenshot APIs +- Satori - Convert HTML/CSS to SVG/PNG + +Current Limitation + +As implemented, this endpoint would need an additional step to be useful for social sharing: + +Your endpoint → Screenshot service → Image file → Social networks + +Better Approaches + +For a production Astro site, you'd typically: + +- Use @vercel/og or Satori to generate actual images server-side +- Pre-generate images at build time for static content +- Use a screenshot service that can be called from your endpoint to return actual images ## @TODO: Use Confetti on CTA forms diff --git a/api/contact/__tests__/contact.manual.ts b/api/contact/__tests__/contact.manual.ts deleted file mode 100644 index 9bc8689c2..000000000 --- a/api/contact/__tests__/contact.manual.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Test script for the contact form API with Resend integration -// Run with: node api/contact.spec.js -// Requires RESEND_API_KEY environment variable to be set - -const testContactAPI = async () => { - const testData = { - name: 'John Doe', - email: 'john.doe@example.com', - company: 'Test Company', - phone: '+1 (555) 123-4567', - project_type: 'website', - budget: '10k-25k', - timeline: '2-3-months', - message: 'This is a test message for the contact form. It contains enough characters to pass validation and demonstrates the form functionality.' - }; - - try { - console.log('Testing contact form API...'); - console.log('Test data:', testData); - - const response = await fetch('http://localhost:4322/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(testData), - }); - - const result = await response.json(); - - console.log('Response status:', response.status); - console.log('Response data:', result); - - if (response.ok) { - console.log('✅ Test passed! Contact form API is working.'); - } else { - console.log('❌ Test failed:', result.error); - } - - } catch (error) { - console.error('❌ Test error:', error instanceof Error ? error.message : String(error)); - } -}; - -// Rate limiting test -const testRateLimit = async () => { - console.log('\nTesting rate limiting...'); - - const testData = { - name: 'Rate Test', - email: 'test@example.com', - message: 'Rate limiting test message.' - }; - - for (let i = 1; i <= 7; i++) { - try { - const response = await fetch('http://localhost:4322/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(testData), - }); - - const result = await response.json(); - console.log(`Request ${i}: Status ${response.status} - ${result.success ? 'Success' : result.error}`); - - if (response.status === 429) { - console.log('✅ Rate limiting is working correctly!'); - break; - } - - // Small delay between requests - await new Promise(resolve => setTimeout(resolve, 500)); - - } catch (error) { - console.error(`Request ${i} error:`, error instanceof Error ? error.message : 'Unknown error'); - } - } -}; - -// Input validation test -const testValidation = async () => { - console.log('\nTesting input validation...'); - - const invalidData = [ - { name: '', email: 'valid@example.com', message: 'Valid message' }, - { name: 'Valid Name', email: 'invalid-email', message: 'Valid message' }, - { name: 'Valid Name', email: 'valid@example.com', message: 'Short' }, - { name: 'Valid Name', email: 'valid@example.com', message: 'This message contains spam keywords like bitcoin and crypto and casino' } - ]; - - for (let i = 0; i < invalidData.length; i++) { - try { - const response = await fetch('http://localhost:4322/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(invalidData[i]), - }); - - const result = await response.json(); - console.log(`Validation test ${i + 1}: ${result.error || 'Unexpected success'}`); - - } catch (error) { - console.error(`Validation test ${i + 1} error:`, error instanceof Error ? error.message : 'Unknown error'); - } - } - - console.log('✅ Input validation tests completed!'); -}; - -// Run all tests -const runAllTests = async () => { - console.log('🧪 Contact Form API Test Suite\n'); - - await testContactAPI(); - await testRateLimit(); - await testValidation(); - - console.log('\n🏁 All tests completed!'); - console.log('\nNote: In development mode, emails are logged to console instead of being sent.'); -}; - -// Check if running directly (ES modules) -if (import.meta.url === `file://${process.argv[1]}`) { - runAllTests().catch(console.error); -} - -export { - testContactAPI, - testRateLimit, - testValidation, - runAllTests -}; \ No newline at end of file diff --git a/api/contact/contact.ts b/api/contact/contact.ts deleted file mode 100644 index 23ab56a16..000000000 --- a/api/contact/contact.ts +++ /dev/null @@ -1,293 +0,0 @@ -// Vercel API function for contact form -import { Resend } from 'resend'; - -// Types -interface ContactFormData { - name: string; - email: string; - company?: string; - phone?: string; - project_type?: string; - budget?: string; - timeline?: string; - message: string; - ip?: string; - userAgent?: string; -} - -interface EmailData { - from: string; - to: string; - subject: string; - text: string; -} - -interface FileData { - name: string; - type: string; - size: number; - buffer?: Buffer; - data?: Buffer; -} - -// Initialize Resend -const resend = new Resend(process.env['RESEND_API_KEY']); - -// Simple in-memory rate limiting (use Redis in production) -const rateLimitStore = new Map(); - -// File upload configuration -const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB in bytes - -// Rate limiting check -function checkRateLimit(ip: string): boolean { - const now = Date.now(); - const windowMs = 15 * 60 * 1000; // 15 minutes - const maxRequests = 5; - const key = `rate_limit_${ip}`; - const requests = rateLimitStore.get(key) || []; - - // Clean old requests - const validRequests = requests.filter(timestamp => now - timestamp < windowMs); - - if (validRequests.length >= maxRequests) { - throw new Error('Too many contact form submissions, please try again later.'); - } - - validRequests.push(now); - rateLimitStore.set(key, validRequests); - return true; -} - -// Validate form input -function validateInput(body: ContactFormData): ContactFormData { - const { name, email, company, phone, project_type, budget, timeline, message } = body; - - // Required fields - if (!name || !email || !message) { - throw new Error('Name, email, and message are required fields.'); - } - - // Email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - throw new Error('Please provide a valid email address.'); - } - - // Length validation - if (name.length < 2 || name.length > 100) { - throw new Error('Name must be between 2 and 100 characters.'); - } - - if (message.length < 10 || message.length > 2000) { - throw new Error('Message must be between 10 and 2000 characters.'); - } - - // Basic spam detection - const spamKeywords = ['viagra', 'casino', 'loan', 'credit', 'bitcoin', 'crypto']; - const lowercaseMessage = message.toLowerCase(); - if (spamKeywords.some(keyword => lowercaseMessage.includes(keyword))) { - throw new Error('Message content flagged as potential spam.'); - } - - return { - name: name.trim(), - email: email.trim().toLowerCase(), - company: company?.trim() || '', - phone: phone?.trim() || '', - project_type: project_type || '', - budget: budget || '', - timeline: timeline || '', - message: message.trim() - }; -} - -// Generate email content -function generateEmailContent(data: ContactFormData, files: FileData[] = []): string { - const { name, email, company, phone, project_type, budget, timeline, message } = data; - - let emailBody = ` -New contact form submission from Webstack Builders website: - -Name: ${name} -Email: ${email} -Company: ${company || 'Not provided'} -Phone: ${phone || 'Not provided'} -Project Type: ${project_type || 'Not specified'} -Budget: ${budget || 'Not specified'} -Timeline: ${timeline || 'Not specified'} - -Message: -${message} - ---- -Submitted: ${new Date().toISOString()} -IP: ${data.ip || 'Unknown'} -User Agent: ${data.userAgent || 'Unknown'} -`; - - // Add file information if files are attached - if (files && files.length > 0) { - emailBody += `\n\nAttached Files (${files.length}):\n`; - files.forEach((file, index) => { - emailBody += `${index + 1}. ${file.name} (${file.type}, ${(file.size / 1024).toFixed(2)}KB)\n`; - }); - } - - return emailBody; -} - -// Send email with attachments using Resend -async function sendEmail(emailData: EmailData, files: FileData[] = []): Promise { - try { - // Prepare email options - const emailOptions = { - from: 'contact@webstackbuilders.com', // Use your verified domain - to: 'kevin@webstackbuilders.com', - replyTo: emailData.from, - subject: emailData.subject, - text: emailData.text, - attachments: files && files.length > 0 ? files.map(file => ({ - filename: file.name, - content: file.buffer || file.data || Buffer.from(''), // Use buffer or data depending on multipart parser - contentType: file.type - })) : [] - }; - - // Send email via Resend - const response = await resend.emails.send(emailOptions); - - console.log('Email sent successfully via Resend:', response.data?.id); - return { - messageId: response.data?.id || 'unknown', - success: true, - attachments: files.length - }; - - } catch (error) { - console.error('Resend email error:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - throw new Error(`Failed to send email: ${errorMessage}`); - } -} - -// Main Vercel API handler -export default async function handler(req: any, res: any): Promise { - // CORS headers - res.setHeader('Access-Control-Allow-Credentials', true); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - // Handle preflight - if (req.method === 'OPTIONS') { - return res.status(200).end(); - } - - // Only allow POST requests - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - try { - // Get client IP - const ip = req.headers['x-forwarded-for'] || req.connection?.remoteAddress || 'unknown'; - - // Check rate limit - checkRateLimit(ip); - - // Parse form data (handle both JSON and multipart) - let formData = {}; - let files = []; - - if (req.headers['content-type']?.includes('multipart/form-data')) { - // Handle multipart form data with files - // In a real implementation, you'd use a library like 'multiparty' or 'formidable' - // For now, assume files are parsed and available in req.files - formData = req.body || {}; - files = req.files || []; - - // Validate file types and sizes - if (files.length > 0) { - for (const file of files) { - // Check file size - if (file.size > MAX_ATTACHMENT_SIZE) { - throw new Error(`File "${file.name}" exceeds ${MAX_ATTACHMENT_SIZE / (1024 * 1024)}MB limit`); - } - - // Check file type - const allowedTypes = [ - 'application/pdf', 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - 'audio/mpeg', 'audio/wav', 'audio/mp4', - 'video/mp4', 'video/mpeg', 'video/quicktime', - 'application/zip', 'text/plain' - ]; - - if (!allowedTypes.includes(file.type)) { - throw new Error(`File type "${file.type}" is not allowed`); - } - } - - // Limit number of files - if (files.length > 5) { - throw new Error('Maximum 5 files allowed'); - } - } - } else { - // Handle regular JSON data - formData = req.body || {}; - } - - // Add request metadata - const requestData = { - ...formData, - ip: ip, - userAgent: req.headers['user-agent'] || 'Unknown' - } as ContactFormData; - - // Validate input - const validatedData = validateInput(requestData); - - // Generate email content (include file info) - const emailContent = generateEmailContent(validatedData, files); - - // Send email with attachments (implement actual email service in production) - const emailResult = await sendEmail({ - to: 'kevin@webstackbuilders.com', - from: validatedData.email, - subject: `New Contact Form Submission from ${validatedData.name}`, - text: emailContent - }); - - // Success response - res.status(200).json({ - success: true, - message: 'Your message has been sent successfully. We\'ll get back to you soon!', - messageId: emailResult.messageId - }); - - } catch (error) { - console.error('Contact form error:', error); - - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - - // Handle specific error types - if (errorMessage.includes('rate limit')) { - return res.status(429).json({ error: errorMessage }); - } - - if (errorMessage.includes('required fields') || - errorMessage.includes('valid email') || - errorMessage.includes('characters') || - errorMessage.includes('spam')) { - return res.status(400).json({ error: errorMessage }); - } - - // Generic server error - res.status(500).json({ - error: 'An error occurred while sending your message. Please try again later.' - }); - } -} \ No newline at end of file diff --git a/api/contact/index.ts b/api/contact/index.ts deleted file mode 100644 index 71a11aacc..000000000 --- a/api/contact/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Vercel API function for contact form - Entry point -import handler from './contact'; - -// Export the default handler for Vercel Functions -export default handler; \ No newline at end of file diff --git a/api/newsletter/__tests__/index.spec.ts b/api/newsletter/__tests__/index.spec.ts deleted file mode 100644 index c12b2104f..000000000 --- a/api/newsletter/__tests__/index.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -/** - * Unit tests for Newsletter API Entry Point - * - * Tests cover: - * - Default export functionality - * - Handler delegation to newsletter module - * - Vercel function compatibility - * - * Note: This tests the entry point that Vercel uses to invoke the newsletter handler - */ - -describe('Newsletter API Entry Point', () => { - it('should export the newsletter handler as default', async () => { - // Import the index module - const indexModule = await import('../index') - - // Import the newsletter module to compare - const newsletterModule = await import('../newsletter') - - // The default export from index should be the same as the default export from newsletter - expect(indexModule.default).toBe(newsletterModule.default) - }) - - it('should be a function', async () => { - const indexModule = await import('../index') - expect(typeof indexModule.default).toBe('function') - }) - - it('should delegate to newsletter handler', async () => { - // Since index.ts just re-exports the newsletter handler, - // we can test that the import chain works correctly - const indexModule = await import('../index') - const newsletterModule = await import('../newsletter') - - // Both should reference the same function - expect(indexModule.default).toBe(newsletterModule.default) - - // Test that calling the index handler works - const mockReq = { - method: 'OPTIONS', // Use OPTIONS to avoid complex mocking - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - // Call the handler from index - await indexModule.default(mockReq, mockRes) - - // Should handle OPTIONS request properly - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.end).toHaveBeenCalled() - }) - - it('should maintain function signature compatibility', async () => { - // This test ensures the exported function has the expected signature - // for Vercel serverless functions - const module = await import('../index') - const handler = module.default - - expect(handler).toBeDefined() - expect(typeof handler).toBe('function') - expect(handler.length).toBe(2) // Should accept 2 parameters (req, res) - }) -}) \ No newline at end of file diff --git a/api/newsletter/__tests__/integration.spec.ts b/api/newsletter/__tests__/integration.spec.ts deleted file mode 100644 index 813d1a203..000000000 --- a/api/newsletter/__tests__/integration.spec.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import handler from '../newsletter' - -// Mock the new dependencies for double opt-in flow -vi.mock('../token', () => ({ - createPendingSubscription: vi.fn(), -})) - -vi.mock('../email', () => ({ - sendConfirmationEmail: vi.fn(), -})) - -vi.mock('../../shared/consent-log', () => ({ - recordConsent: vi.fn(), -})) - -describe('Newsletter API Integration Tests', () => { - const originalEnv = process.env - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let createPendingSubscription: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let sendConfirmationEmail: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let recordConsent: any - - beforeEach(async () => { - // Mock environment variables - process.env = { - ...originalEnv, - CONVERTKIT_API_KEY: 'test-api-key', - CONVERTKIT_FORM_ID: 'test-form-id', - RESEND_API_KEY: 'test-resend-key', - SITE_URL: 'http://localhost:4321', - } - - // Import the mocked modules - const tokenModule = await import('../token') - const emailModule = await import('../email') - const consentModule = await import('../../shared/consent-log') - - createPendingSubscription = tokenModule.createPendingSubscription - sendConfirmationEmail = emailModule.sendConfirmationEmail - recordConsent = consentModule.recordConsent - - // Set up default mock implementations - createPendingSubscription.mockResolvedValue('test-token-123') - sendConfirmationEmail.mockResolvedValue(undefined) - recordConsent.mockResolvedValue(undefined) - - // Mock console methods to suppress logs during tests - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - process.env = originalEnv - vi.restoreAllMocks() - }) - - describe('Complete Workflow Integration', () => { - it('should handle complete double opt-in workflow for new user', async () => { - const mockReq = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'origin': 'https://webstackbuilders.com', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - // Should record consent - expect(recordConsent).toHaveBeenCalledWith({ - email: 'test@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - verified: false, - }) - - // Should create pending subscription - expect(createPendingSubscription).toHaveBeenCalledWith({ - email: 'test@example.com', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - source: 'newsletter_form', - }) - - // Should send confirmation email - expect(sendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - undefined - ) - - // Should return success with confirmation message - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }) - }) - - it('should handle subscription with name', async () => { - const mockReq = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'origin': 'https://webstackbuilders.com', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '192.168.1.100' }, - body: { - email: 'jane@example.com', - firstName: 'Jane Smith', - consentGiven: true, - }, - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - // Should include first name in pending subscription - expect(createPendingSubscription).toHaveBeenCalledWith({ - email: 'jane@example.com', - firstName: 'Jane Smith', - userAgent: 'test-agent', - ipAddress: '192.168.1.100', - source: 'newsletter_form', - }) - - // Should include first name in confirmation email - expect(sendConfirmationEmail).toHaveBeenCalledWith( - 'jane@example.com', - 'test-token-123', - 'Jane Smith' - ) - - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }) - }) - - it('should handle API errors gracefully', async () => { - sendConfirmationEmail.mockRejectedValueOnce(new Error('Email service error')) - - const mockReq = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Email service error', - }) - }) - }) - - describe('Input Validation Integration', () => { - it('should validate email format', async () => { - const mockReq = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'not-an-email', consentGiven: true }, - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(recordConsent).not.toHaveBeenCalled() - expect(sendConfirmationEmail).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(400) - }) - - it('should handle missing email', async () => { - const mockReq = { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '127.0.0.1' }, - body: { consentGiven: true }, - } - - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(recordConsent).not.toHaveBeenCalled() - expect(sendConfirmationEmail).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(400) - }) - }) -}) \ No newline at end of file diff --git a/api/newsletter/__tests__/newsletter.spec.ts b/api/newsletter/__tests__/newsletter.spec.ts deleted file mode 100644 index 9ed752b4b..000000000 --- a/api/newsletter/__tests__/newsletter.spec.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' - -/** - * Unit tests for Newsletter API Handler (Double Opt-in Flow) - * - * Tests cover: - * - HTTP method validation - * - CORS headers - * - Input validation - * - GDPR consent validation - * - Double opt-in flow (token + email) - * - Error handling - * - Rate limiting - * - * Note: These tests focus on the main handler function behavior - * with comprehensive mocking of external dependencies. - */ - -// Mock the new dependencies for double opt-in flow -vi.mock('../token', () => ({ - createPendingSubscription: vi.fn(), -})) - -vi.mock('../email', () => ({ - sendConfirmationEmail: vi.fn(), -})) - -vi.mock('../../shared/consent-log', () => ({ - recordConsent: vi.fn(), -})) - -// Mock console methods -vi.spyOn(console, 'error').mockImplementation(() => {}) -vi.spyOn(console, 'log').mockImplementation(() => {}) - -describe('Newsletter API Handler', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let handler: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let createPendingSubscription: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let sendConfirmationEmail: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let recordConsent: any - const originalEnv = process.env - - beforeEach(async () => { - vi.clearAllMocks() - - // Set up test environment - process.env = { ...originalEnv } - process.env['CONVERTKIT_API_KEY'] = 'test-api-key' - process.env['RESEND_API_KEY'] = 'test-resend-key' - process.env['SITE_URL'] = 'http://localhost:4321' - - // Import the mocked modules - const tokenModule = await import('../token') - const emailModule = await import('../email') - const consentModule = await import('../../shared/consent-log') - - createPendingSubscription = tokenModule.createPendingSubscription - sendConfirmationEmail = emailModule.sendConfirmationEmail - recordConsent = consentModule.recordConsent - - // Set up default mock implementations - createPendingSubscription.mockResolvedValue('test-token-123') - sendConfirmationEmail.mockResolvedValue(undefined) - recordConsent.mockResolvedValue(undefined) - - // Import the handler - const module = await import('../newsletter') - handler = module.default - }) - - afterEach(() => { - process.env = originalEnv - vi.restoreAllMocks() - }) - - describe('HTTP Method Validation', () => { - it('should handle OPTIONS method for CORS preflight', async () => { - const mockReq = { method: 'OPTIONS' } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*') - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.end).toHaveBeenCalled() - }) - - it('should reject non-POST methods', async () => { - const mockReq = { method: 'GET' } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(405) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Method not allowed', - }) - }) - }) - - describe('CORS Headers', () => { - it('should set proper CORS headers', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*') - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'POST, OPTIONS') - expect(mockRes.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Headers', 'Content-Type') - }) - }) - - describe('Input Validation', () => { - it('should reject missing email', async () => { - const mockReq = { - method: 'POST', - headers: {}, - socket: { remoteAddress: '127.0.0.1' }, - body: {}, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Email address is required.', - }) - }) - - it('should reject invalid email format', async () => { - const mockReq = { - method: 'POST', - headers: {}, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'invalid-email' }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Email address is invalid', - }) - }) - }) - - describe('Successful Subscriptions (Double Opt-in)', () => { - it('should require GDPR consent', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: false }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'You must consent to receive marketing emails to subscribe.', - }) - expect(createPendingSubscription).not.toHaveBeenCalled() - expect(sendConfirmationEmail).not.toHaveBeenCalled() - }) - - it('should handle successful double opt-in initiation with email only', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(recordConsent).toHaveBeenCalledWith({ - email: 'test@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - verified: false, - }) - - expect(createPendingSubscription).toHaveBeenCalledWith({ - email: 'test@example.com', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - source: 'newsletter_form', - }) - - expect(sendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - undefined - ) - - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }) - }) - - it('should handle successful double opt-in initiation with email and name', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'jane@example.com', firstName: 'Jane', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - expect(recordConsent).toHaveBeenCalledWith({ - email: 'jane@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - verified: false, - }) - - expect(createPendingSubscription).toHaveBeenCalledWith({ - email: 'jane@example.com', - firstName: 'Jane', - userAgent: 'test-agent', - ipAddress: '127.0.0.1', - source: 'newsletter_form', - }) - - expect(sendConfirmationEmail).toHaveBeenCalledWith( - 'jane@example.com', - 'test-token-123', - 'Jane' - ) - - expect(mockRes.status).toHaveBeenCalledWith(200) - expect(mockRes.json).toHaveBeenCalledWith({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }) - }) - }) - - describe('Error Handling', () => { - it('should handle email sending errors', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - sendConfirmationEmail.mockRejectedValueOnce(new Error('Email service error')) - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Email service error', - }) - }) - - it('should handle token creation errors', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - createPendingSubscription.mockRejectedValueOnce(new Error('Token generation failed')) - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Token generation failed', - }) - }) - - it('should handle consent recording errors', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - recordConsent.mockRejectedValueOnce(new Error('Database error')) - - await handler(mockReq, mockRes) - - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Database error', - }) - }) - }) - - describe('Rate Limiting', () => { - it('should enforce rate limits', async () => { - const mockReq = { - method: 'POST', - headers: { 'user-agent': 'test-agent' }, - socket: { remoteAddress: '192.168.1.100' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - // Make 10 requests (should succeed) - for (let i = 0; i < 10; i++) { - vi.clearAllMocks() - await handler(mockReq, mockRes) - expect(mockRes.status).toHaveBeenCalledWith(200) - } - - // 11th request should be rate limited - vi.clearAllMocks() - await handler(mockReq, mockRes) - expect(mockRes.status).toHaveBeenCalledWith(429) - expect(mockRes.json).toHaveBeenCalledWith({ - success: false, - error: 'Too many subscription requests. Please try again later.', - }) - }) - }) - - describe('IP Address Extraction', () => { - it('should extract IP from x-forwarded-for header', async () => { - const mockReq = { - method: 'POST', - headers: { - 'x-forwarded-for': '203.0.113.1, 10.0.0.1', - 'user-agent': 'test-agent', - }, - socket: { remoteAddress: '127.0.0.1' }, - body: { email: 'test@example.com', consentGiven: true }, - } - const mockRes = { - setHeader: vi.fn(), - status: vi.fn(() => mockRes), - json: vi.fn(() => mockRes), - end: vi.fn(() => mockRes), - } - - await handler(mockReq, mockRes) - - // Should use IP from x-forwarded-for header (203.0.113.1) - expect(recordConsent).toHaveBeenCalledWith({ - email: 'test@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'test-agent', - ipAddress: '203.0.113.1', - verified: false, - }) - - expect(mockRes.status).toHaveBeenCalledWith(200) - }) - }) -}) \ No newline at end of file diff --git a/api/newsletter/email.ts b/api/newsletter/email.ts index 8da4cb67b..965c3d902 100644 --- a/api/newsletter/email.ts +++ b/api/newsletter/email.ts @@ -202,6 +202,14 @@ export async function sendConfirmationEmail( token: string, firstName?: string ): Promise { + // Skip actual email sending in dev/test environments + const isDevOrTest = process.env['NODE_ENV'] === 'development' || process.env['NODE_ENV'] === 'test' || process.env['CI'] === 'true' + + if (isDevOrTest) { + console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) + return + } + const resend = getResendClient() const siteUrl = getSiteUrl() const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` @@ -247,6 +255,14 @@ export async function sendWelcomeEmail( email: string, firstName?: string ): Promise { + // Skip actual email sending in dev/test environments + const isDevOrTest = process.env['NODE_ENV'] === 'development' || process.env['NODE_ENV'] === 'test' || process.env['CI'] === 'true' + + if (isDevOrTest) { + console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) + return + } + const resend = getResendClient() const greeting = firstName ? `Hi ${firstName}` : 'Hello' diff --git a/api/newsletter/index.ts b/api/newsletter/index.ts deleted file mode 100644 index 1cfe4c2ce..000000000 --- a/api/newsletter/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Vercel API function for newsletter signup - Entry point -import handler from './newsletter' - -// Export the default handler for Vercel Functions -export default handler - -// Export utility functions for use elsewhere -export { subscribeToConvertKit } from './newsletter' -export { createPendingSubscription, confirmSubscription, validateToken } from './token' -export { sendConfirmationEmail, sendWelcomeEmail } from './email' diff --git a/api/newsletter/newsletter.ts b/api/newsletter/newsletter.ts deleted file mode 100644 index 0e6ee1d16..000000000 --- a/api/newsletter/newsletter.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Vercel API function for ConvertKit newsletter subscription -// Implements GDPR-compliant double opt-in flow - -import { createPendingSubscription } from './token' -import { sendConfirmationEmail } from './email' -import { recordConsent } from '../shared/consent-log' - -// Types -interface NewsletterFormData { - email: string - firstName?: string - consentGiven?: boolean -} - -interface ConvertKitSubscriber { - email_address: string; - first_name?: string; - state?: 'active' | 'inactive'; - fields?: Record; -} - -interface ConvertKitResponse { - subscriber: { - id: number; - first_name: string | null; - email_address: string; - state: string; - created_at: string; - fields: Record; - }; -} - -interface ConvertKitErrorResponse { - errors: string[]; -} - -// Simple in-memory rate limiting (use Redis in production) -const rateLimitStore = new Map(); - -/** - * Check if the IP address has exceeded the rate limit - * @param ip - Client IP address - * @returns true if within rate limit, false if exceeded - */ -function checkRateLimit(ip: string): boolean { - const now = Date.now(); - const windowMs = 15 * 60 * 1000; // 15 minutes - const maxRequests = 10; // More lenient for newsletter signups - const key = `newsletter_rate_limit_${ip}`; - const requests = rateLimitStore.get(key) || []; - - // Clean old requests - const validRequests = requests.filter(timestamp => now - timestamp < windowMs); - - if (validRequests.length >= maxRequests) { - return false; - } - - validRequests.push(now); - rateLimitStore.set(key, validRequests); - return true; -} - -/** - * Validate email address format - * @param email - Email address to validate - * @returns Validated and normalized email address - */ -function validateEmail(email: string): string { - if (!email) { - throw new Error('Email address is required.'); - } - - // Email validation - same pattern as client-side - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - throw new Error('Email address is invalid'); - } - - return email.trim().toLowerCase(); -} - -/** - * Subscribe email to ConvertKit - * NOTE: This function will be called from the confirmation page after email verification - * @param data - Newsletter form data - * @returns ConvertKit API response - */ -export async function subscribeToConvertKit(data: NewsletterFormData): Promise { - const apiKey = process.env['CONVERTKIT_API_KEY']; - - if (!apiKey) { - throw new Error('ConvertKit API key is not configured.'); - } - - /* eslint-disable camelcase */ - // ConvertKit API requires snake_case property names - const subscriberData: ConvertKitSubscriber = { - email_address: data.email, - state: 'active', - }; - - // Add first name if provided - if (data.firstName) { - subscriberData.first_name = data.firstName.trim(); - } - /* eslint-enable camelcase */ - - try { - const response = await fetch('https://api.kit.com/v4/subscribers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Kit-Api-Key': apiKey, - }, - body: JSON.stringify(subscriberData), - }); - - const responseData = await response.json(); - - // Handle different response codes - if (response.status === 401) { - const errorData = responseData as ConvertKitErrorResponse; - console.error('ConvertKit API authentication failed:', errorData.errors); - throw new Error('Newsletter service configuration error. Please contact support.'); - } - - if (response.status === 422) { - const errorData = responseData as ConvertKitErrorResponse; - throw new Error(errorData.errors[0] || 'Invalid email address'); - } - - // Success: 200 (updated), 201 (created), 202 (accepted) - if (response.status === 200 || response.status === 201 || response.status === 202) { - return responseData as ConvertKitResponse; - } - - // Unexpected response - throw new Error('An unexpected error occurred. Please try again later.'); - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error('Failed to connect to newsletter service. Please try again later.'); - } -} - -/** - * Main API handler for newsletter subscriptions - * Implements GDPR-compliant double opt-in flow - * @param req - Vercel request object - * @param res - Vercel response object - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default async function handler(req: any, res: any): Promise { - // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') - - // Handle OPTIONS for CORS preflight - if (req.method === 'OPTIONS') { - return res.status(200).end() - } - - // Only allow POST - if (req.method !== 'POST') { - return res.status(405).json({ - success: false, - error: 'Method not allowed', - }) - } - - try { - // Get client IP and user agent for audit trail - const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] || - req.socket.remoteAddress || - 'unknown' - const userAgent = req.headers['user-agent'] || 'unknown' - - // Check rate limit - if (!checkRateLimit(ip)) { - return res.status(429).json({ - success: false, - error: 'Too many subscription requests. Please try again later.', - }) - } - - // Parse and validate input - const { email, firstName, consentGiven } = req.body as NewsletterFormData - const validatedEmail = validateEmail(email) - - // Validate GDPR consent - if (!consentGiven) { - return res.status(400).json({ - success: false, - error: 'You must consent to receive marketing emails to subscribe.', - }) - } - - // Record initial (unverified) consent - await recordConsent({ - email: validatedEmail, - purposes: ['marketing'], - source: 'newsletter_form', - userAgent, - ...(ip !== 'unknown' && { ipAddress: ip }), - verified: false, // Will be set to true after email confirmation - }) - - // Create pending subscription with token - const token = await createPendingSubscription({ - email: validatedEmail, - ...(firstName && { firstName }), - userAgent, - ...(ip !== 'unknown' && { ipAddress: ip }), - source: 'newsletter_form', - }) - - // Send confirmation email - await sendConfirmationEmail(validatedEmail, token, firstName) - - // Return success response asking user to check email - return res.status(200).json({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }) - - } catch (error) { - console.error('Newsletter subscription error:', error) - - // Return user-friendly error - const errorMessage = error instanceof Error - ? error.message - : 'An unexpected error occurred. Please try again.' - - return res.status(400).json({ - success: false, - error: errorMessage, - }) - } -} diff --git a/api/newsletter/token.ts b/api/newsletter/token.ts index 9bdcfbcb0..39f91e7c9 100644 --- a/api/newsletter/token.ts +++ b/api/newsletter/token.ts @@ -33,7 +33,7 @@ export function generateConfirmationToken(): string { // Generate 32 random bytes and encode as base64url (URL-safe) const array = new Uint8Array(32) crypto.getRandomValues(array) - return btoa(String.fromCharCode(...array)) + return Buffer.from(array).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') diff --git a/astro.config.ts b/astro.config.ts index 876807335..736d10080 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -58,7 +58,7 @@ export default defineConfig({ }, }, ], - output: 'static', + output: 'static', // Most pages are static; API routes will be marked for SSR prefetch: true, site: getSiteUrl(), // Change URL between development and production environments trailingSlash: 'never', diff --git a/axe-results-incomplete.json b/axe-results-incomplete.json new file mode 100644 index 000000000..7861c1e80 --- /dev/null +++ b/axe-results-incomplete.json @@ -0,0 +1,1175 @@ +[ + { + "id": "color-contrast", + "impact": "serious", + "tags": [ + "cat.color", + "wcag2aa", + "wcag143", + "TTv5", + "TT13.c", + "EN-301-549", + "EN-9.1.4.3", + "ACT", + "RGAAv4", + "RGAA-3.2.1" + ], + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "help": "Elements must meet minimum color contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.11/color-contrast?application=playwright", + "nodes": [ + { + "any": [ + { + "id": "color-contrast", + "data": { + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": "pseudoContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "#header" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a pseudo element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".lg\\:text-lg.tracking-\\[5px\\][href$=\"about\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a pseudo element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": "pseudoContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "#header" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a pseudo element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".lg\\:text-lg.tracking-\\[5px\\][href$=\"articles\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a pseudo element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": "pseudoContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "#header" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a pseudo element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".lg\\:text-lg.tracking-\\[5px\\][href$=\"case-studies\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a pseudo element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": "pseudoContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "#header" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a pseudo element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".lg\\:text-lg.tracking-\\[5px\\][href$=\"services\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a pseudo element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": "pseudoContent", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "#header" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a pseudo element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".lg\\:text-lg.tracking-\\[5px\\][href$=\"contact\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a pseudo element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".hover\\:border-\\[var\\(--color-primary\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "27.0pt (36px)", + "fontWeight": "bold", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nBuilding the Future of Software Development\n

", + "target": [ + ".max-w-3xl.mx-auto > .mb-6.md\\:text-4xl.text-3xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "15.0pt (20px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

", + "target": [ + ".mb-8.leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "13.5pt (18px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nPlatform Engineering\n

", + "target": [ + ".text-center:nth-child(1) > .text-lg.mb-2" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nScalable infrastructure and developer-first platforms\n

", + "target": [ + ".md\\:grid-cols-3 > .text-center:nth-child(1) > .text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "13.5pt (18px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nCloud Architecture\n

", + "target": [ + ".text-center:nth-child(2) > .text-lg.mb-2" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nAzure-focused solutions that scale with your business\n

", + "target": [ + ".text-center:nth-child(2) > .text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "13.5pt (18px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nDeveloper Experience\n

", + "target": [ + ".text-center:nth-child(3) > .text-lg.mb-2" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nTools and workflows that empower development teams\n

", + "target": [ + ".text-center:nth-child(3) > .text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "27.0pt (36px)", + "fontWeight": "bold", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Featured Services

", + "target": [ + ".max-w-6xl.py-16:nth-child(3) > .mb-12 > .md\\:text-4xl.text-3xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "27.0pt (36px)", + "fontWeight": "bold", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nLatest Insights\n

", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .mb-12 > .md\\:text-4xl.text-3xl.mb-4" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "15.0pt (20px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nStay up-to-date with the latest trends, best practices, and insights in software\n development and platform engineering.\n

", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .mb-12 > .max-w-3xl.text-\\[color\\:var\\(--color-text-offset\\)\\].text-xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "27.0pt (36px)", + "fontWeight": "bold", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Latest Insights

", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .mb-12 > .md\\:text-4xl.text-3xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "
", + "target": [ + ".border-\\[color\\:var\\(--color-primary\\)\\].py-3[href$=\"articles\"]" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "36.0pt (48px)", + "fontWeight": "bold", + "messageKey": "bgGradient", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:py-24" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a background gradient" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Ready to Transform Your Development Process?

", + "target": [ + ".lg\\:text-5xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a background gradient" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "15.0pt (20px)", + "fontWeight": "normal", + "messageKey": "bgGradient", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:py-24" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a background gradient" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

", + "target": [ + ".md\\:text-xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a background gradient" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": "bgGradient", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "

", + "target": [ + ".md\\:py-24" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined due to a background gradient" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".border-white" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined due to a background gradient" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "22.5pt (30px)", + "fontWeight": "bold", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "3:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nTechnologies & Expertise\n

", + "target": [ + ".md\\:text-3xl.mb-8.text-2xl" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
Go
", + "target": [ + ".flex-col.items-center.flex:nth-child(1) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Go", + "target": [ + ".flex-col.items-center.flex:nth-child(1) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Go", + "target": [ + ".flex-col.items-center.flex:nth-child(1) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
TS
", + "target": [ + ".flex-col.items-center.flex:nth-child(2) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "TS", + "target": [ + ".flex-col.items-center.flex:nth-child(2) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "TypeScript", + "target": [ + ".flex-col.items-center.flex:nth-child(2) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
TF
", + "target": [ + ".flex-col.items-center.flex:nth-child(3) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "TF", + "target": [ + ".flex-col.items-center.flex:nth-child(3) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Terraform", + "target": [ + ".flex-col.items-center.flex:nth-child(3) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
Az
", + "target": [ + ".flex-col.items-center.flex:nth-child(4) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Az", + "target": [ + ".flex-col.items-center.flex:nth-child(4) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Azure", + "target": [ + ".flex-col.items-center.flex:nth-child(4) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
K8s
", + "target": [ + ".flex-col.items-center.flex:nth-child(5) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "K8s", + "target": [ + ".flex-col.items-center.flex:nth-child(5) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Kubernetes", + "target": [ + ".flex-col.items-center.flex:nth-child(5) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#006dca", + "contrastRatio": 1, + "fontSize": "10.5pt (14px)", + "fontWeight": "bold", + "messageKey": "equalRatio", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
Py
", + "target": [ + ".flex-col.items-center.flex:nth-child(6) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has a 1:1 contrast ratio with the background" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Py", + "target": [ + ".flex-col.items-center.flex:nth-child(6) > .bg-opacity-10.mb-2.bg-\\[color\\:var\\(--color-primary\\)\\] > .text-\\[color\\:var\\(--color-primary\\)\\].font-bold.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has a 1:1 contrast ratio with the background" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscured", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "body" + ] + } + ], + "impact": "serious", + "message": "Element's background color could not be determined because it's partially obscured by another element" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Python", + "target": [ + ".flex-col.items-center.flex:nth-child(6) > .font-medium.text-\\[color\\:var\\(--color-text\\)\\].text-sm" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it's partially obscured by another element" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "contrastRatio": 0, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": "elmPartiallyObscuring", + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [], + "impact": "serious", + "message": "Element's background color could not be determined because it partially overlaps other elements" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nThis site uses cookies. Learn more in our Cookie Policy and Privacy Policy.\n

", + "target": [ + "#cookie-modal__content" + ], + "failureSummary": "Fix any of the following:\n Element's background color could not be determined because it partially overlaps other elements" + } + ] + } +] \ No newline at end of file diff --git a/axe-results-violations.json b/axe-results-violations.json new file mode 100644 index 000000000..1f7c132b2 --- /dev/null +++ b/axe-results-violations.json @@ -0,0 +1,703 @@ +[ + { + "id": "color-contrast", + "impact": "serious", + "tags": [ + "cat.color", + "wcag2aa", + "wcag143", + "TTv5", + "TT13.c", + "EN-301-549", + "EN-9.1.4.3", + "ACT", + "RGAAv4", + "RGAA-3.2.1" + ], + "description": "Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds", + "help": "Elements must meet minimum color contrast ratio thresholds", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.11/color-contrast?application=playwright", + "nodes": [ + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#e5e7eb", + "contrastRatio": 2.05, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:p-8" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "
\nUp to:\n
", + "target": [ + ".text-\\[var\\(--color-text-offset\\)\\].tracking-wide.mb-4" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#e5e7eb", + "contrastRatio": 4.19, + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:p-8" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "57%", + "target": [ + ".items-start.gap-3:nth-child(1) > .md\\:text-lg.text-base.text-\\[var\\(--color-text\\)\\] > strong" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#e5e7eb", + "contrastRatio": 4.19, + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:p-8" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "$114", + "target": [ + ".items-start.gap-3:nth-child(2) > .md\\:text-lg.text-base.text-\\[var\\(--color-text\\)\\] > strong" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#e5e7eb", + "contrastRatio": 4.19, + "fontSize": "13.5pt (18px)", + "fontWeight": "bold", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:p-8" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "Up to 400%", + "target": [ + ".items-start.gap-3:nth-child(3) > .md\\:text-lg.text-base.text-\\[var\\(--color-text\\)\\] > strong" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 4.19 (foreground color: #006dca, background color: #e5e7eb, font size: 13.5pt (18px), font weight: bold). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#374151", + "bgColor": "#006dca", + "contrastRatio": 1.98, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + "a[href$=\"web-development\"]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 1.98 (foreground color: #374151, background color: #006dca, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + "a[href$=\"web-development\"]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 1.98 (foreground color: #374151, background color: #006dca, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#374151", + "bgColor": "#006dca", + "contrastRatio": 1.98, + "fontSize": "12.0pt (16px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "", + "target": [ + ".hover\\:bg-\\[color\\:var\\(--color-primary-offset\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 1.98 (foreground color: #374151, background color: #006dca, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "", + "target": [ + ".hover\\:bg-\\[color\\:var\\(--color-primary-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 1.98 (foreground color: #374151, background color: #006dca, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#e5e7eb", + "contrastRatio": 2.05, + "fontSize": "15.0pt (20px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5)" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 15.0pt (20px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nDiscover how I've helped businesses transform their technical infrastructure and achieve\n their goals.\n

", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .mb-12 > .max-w-3xl.text-\\[color\\:var\\(--color-text-offset\\)\\].text-xl" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 15.0pt (20px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(1) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Building a scalable API platform to power integrations across multiple business units

", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(1) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(2) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

How we helped a retail company modernize their e-commerce platform for better performance and scalability

", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(5) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(2) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "a[href$=\"division-15\"]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

A specialized job board platform designed for division 15 industry professionals

", + "target": [ + "a[href$=\"division-15\"] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(1) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Essential TypeScript patterns and practices to write safer, more maintainable code

", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(1) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(2) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Learn the fundamentals of building fast, content-focused websites with Astro

", + "target": [ + ".py-16:nth-child(6) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .max-w-6xl.py-16 > .embla.relative[data-carousel-managed=\"true\"] > .embla__viewport.overflow-hidden > .embla__container.md\\:gap-6.gap-4 > .md\\:flex-\\[0_0_50\\%\\].lg\\:flex-\\[0_0_33\\.333\\%\\].embla__slide:nth-child(2) > .group > .hover\\:-translate-y-2.hover\\:shadow-xl.bg-\\[color\\:var\\(--color-bg\\)\\] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + "a[href$=\"demo\"]" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

This is a demonstration article showcasing all available markdown components. Feel free to explore the various components and their usage patterns.

", + "target": [ + "a[href$=\"demo\"] > .p-6.pt-2 > .leading-relaxed.text-\\[color\\:var\\(--color-text-offset\\)\\]" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#e5e7eb", + "contrastRatio": 2.05, + "fontSize": "15.0pt (20px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(7)" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 15.0pt (20px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

\nDon't just take my word for it - hear from the businesses I've helped transform their\n technical infrastructure.\n

", + "target": [ + ".bg-\\[color\\:var\\(--color-bg-offset\\)\\].py-16:nth-child(7) > .max-w-6xl.sm\\:px-6.lg\\:px-8 > .mb-12 > .max-w-3xl.text-\\[color\\:var\\(--color-text-offset\\)\\].text-xl" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 15.0pt (20px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#e5e7eb", + "contrastRatio": 2.05, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".embla__slide.flex-\\[0_0_100\\%\\].min-w-0:nth-child(1) > .bg-bg-offset.p-8.border-border" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "
english-first
", + "target": [ + ".embla__slide.flex-\\[0_0_100\\%\\].min-w-0:nth-child(1) > .bg-bg-offset.p-8.border-border > .gap-4.items-center.flex > div:nth-child(2) > .text-text-offset.capitalize.text-sm" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.05 (foreground color: #9ca3af, background color: #e5e7eb, font size: 10.5pt (14px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#9ca3af", + "bgColor": "#f3f4f6", + "contrastRatio": 2.3, + "fontSize": "13.5pt (18px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "
", + "target": [ + ".md\\:p-12" + ] + } + ], + "impact": "serious", + "message": "Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1" + } + ], + "all": [], + "none": [], + "impact": "serious", + "html": "

Get the latest insights on platform engineering, cloud architecture, and developer productivity delivered to your inbox. No spam, just valuable content for technical leaders.

", + "target": [ + ".max-w-2xl.text-\\[var\\(--color-text-offset\\)\\].md\\:text-lg" + ], + "failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 2.3 (foreground color: #9ca3af, background color: #f3f4f6, font size: 13.5pt (18px), font weight: normal). Expected contrast ratio of 4.5:1" + }, + { + "any": [ + { + "id": "color-contrast", + "data": { + "fgColor": "#006dca", + "bgColor": "#e5e7eb", + "contrastRatio": 4.19, + "fontSize": "10.5pt (14px)", + "fontWeight": "normal", + "messageKey": null, + "expectedContrastRatio": "4.5:1" + }, + "relatedNodes": [ + { + "html": "