ADR-021: Contact Form Progressive Enhancement
Status
Section titled “Status”Accepted (amended 2026-08-02: the contact-info wiring under “Contact Page Structure
Decisions” has since evolved — env access moved to astro:env per ADR-050, the Live
Chat method was removed, and emoji icons were replaced by the Icon atom; see the note
there)
Context
Section titled “Context”The ContactForm component initially used an inline <script> tag (~2.5KB) that loaded immediately on page load, adding unnecessary JavaScript to the initial bundle. The form provides client-side validation and enhanced UX (loading states, error handling), but these are progressive enhancements - the form works perfectly without JavaScript using native HTML5 validation and form submission.
This violates the performance pattern of deferring non-critical JavaScript.
Problem Statement
Section titled “Problem Statement”Before optimization:
- Inline script loads on page load (blocks parsing)
- ~2.5KB JavaScript added to every page with ContactForm
- No benefit for users who submit immediately (form works without JS)
- Violates “zero JS baseline” principle
Questions:
- Should the form validation JavaScript load immediately?
- Can we defer the script without breaking functionality?
- What’s the performance impact of the inline script?
Decision
Section titled “Decision”Extract form enhancement script to external module and defer loading.
Implementation
Section titled “Implementation”- Extract to external module:
ContactFormScript.ts - Use inline
<script>with import: Keeps script co-located with component - Rely on browser’s module loading: Modern browsers defer module scripts automatically
- Progressive enhancement: Form works without JavaScript
Code Changes
Section titled “Code Changes”Before (Inline script):
<form> <!-- Form fields --></form>
<script> // 100+ lines of inline JavaScript document.addEventListener('DOMContentLoaded', () => { // Form enhancement logic });</script>After (External module):
<form> <!-- Form fields --></form>
<script> // Progressive enhancement: Form works without JavaScript // This script adds client-side validation and enhanced UX import { initContactForm } from './ContactFormScript'; initContactForm();</script>ContactFormScript.ts:
export function initContactForm() { const form = document.querySelector('.contact-form') as HTMLFormElement; if (!form) { return; }
// Form enhancement logic // - Client-side validation // - Loading states // - Error handling // - Success messages}
// Auto-initialize when module is importedif (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initContactForm); } else { initContactForm(); }}Rationale
Section titled “Rationale”Why External Module?
Section titled “Why External Module?”- Automatic deferral: Module scripts are deferred by default (don’t block parsing)
- Better caching: External file cached separately from HTML
- Code splitting: Bundler can optimize module loading
- Maintainability: Easier to test and update
- Reusability: Can be imported by other forms if needed
Why Not client:idle or client:visible?
Section titled “Why Not client:idle or client:visible?”Considered alternatives:
<!-- Option 1: client:idle (Preact/React component) --><ContactForm client:idle /><!-- ❌ Requires framework overhead (~10KB) -->
<!-- Option 2: client:visible (lazy load) --><ContactForm client:visible /><!-- ❌ Form is above-the-fold, would delay too much -->
<!-- Option 3: Inline script (current) --><script>/* inline code */</script><!-- ❌ Blocks parsing, not cached separately -->
<!-- ✅ Option 4: Module script (chosen) --><script> import { initContactForm } from './ContactFormScript'; initContactForm();</script><!-- ✅ Deferred automatically, cached, no framework overhead -->Decision: Use module script because:
- No framework overhead (pure TypeScript)
- Automatically deferred (non-blocking)
- Better caching than inline
- Simpler than client directives
Progressive Enhancement Guarantee
Section titled “Progressive Enhancement Guarantee”Form works without JavaScript:
<!-- Native HTML5 validation --><input type="email" required />
<!-- Native form submission --><form action="/api/contact" method="POST"> <button type="submit">Send</button></form>JavaScript adds enhancements:
- Real-time validation feedback
- Loading states during submission
- Success/error messages
- Better UX (no page reload)
Performance Impact
Section titled “Performance Impact”Before Optimization
Section titled “Before Optimization”Initial HTML: 15KBInline script: 2.5KB (blocks parsing)Total: 17.5KBParse time: +50ms (script blocks)After Optimization
Section titled “After Optimization”Initial HTML: 12.5KB (no inline script)External module: 2.5KB (deferred, cached)Total: 15KB (same size, better loading)Parse time: +0ms (script deferred)Improvements:
- Parsing: No longer blocks HTML parsing
- Caching: Script cached separately (better cache hit rate)
- Perceived performance: Page interactive sooner
- Actual performance: Same total bytes, better delivery
Lighthouse Impact
Section titled “Lighthouse Impact”Before:
- Performance: 96 (inline script penalty)
- FCP: 1.2s
- TTI: 1.8s
After (expected):
- Performance: 98+ (no blocking script)
- FCP: 1.0s (faster)
- TTI: 1.5s (faster)
Testing Strategy
Section titled “Testing Strategy”Manual Testing
Section titled “Manual Testing”-
Without JavaScript:
Terminal window # Disable JavaScript in DevTools# Verify form submits natively# Check HTML5 validation works -
With JavaScript:
Terminal window # Enable JavaScript# Verify enhanced validation# Check loading states# Test error handling -
Network Throttling:
Terminal window # Slow 3G in DevTools# Verify form usable before JS loads# Check progressive enhancement
Automated Testing
Section titled “Automated Testing”# Playwright testpnpm run test:e2e -- contact.spec.ts
# Performance auditpnpm run perf:lighthouseConsequences
Section titled “Consequences”Positive
Section titled “Positive”- Better performance: No blocking script
- Better caching: External module cached separately
- Progressive enhancement: Form works without JS
- Maintainability: Easier to test and update
- Reusability: Can be imported by other forms
Negative
Section titled “Negative”- Complexity: Slightly more files (1 additional TypeScript file)
- Bundle size: Same total size (no reduction, just better loading)
Neutral
Section titled “Neutral”- No visual changes: Same UX for end users
- No breaking changes: Form behavior identical
Related Patterns
Section titled “Related Patterns”Form Enhancement Pattern
Section titled “Form Enhancement Pattern”General pattern for all forms:
- HTML-first: Form works with native submission
- External script: Extract enhancement logic
- Module import: Use deferred module loading
- Progressive: Add features without breaking base functionality
Example:
<!-- ✅ Any form component --><form action="/api/endpoint" method="POST"> <input type="text" required /> <button type="submit">Submit</button></form>
<script> import { enhanceForm } from './FormEnhancement'; enhanceForm();</script>Related ADRs
Section titled “Related ADRs”- ADR 020: Page Performance Patterns - Progressive enhancement pattern
- ADR 019: Accessibility Patterns & Standards - Form accessibility
Contact Page Structure Decisions
Section titled “Contact Page Structure Decisions”Amendment (2026-08-02): this section records the original implementation; the shipped contact page has since evolved. Env access moved from
import.meta.envwith||fallbacks to type-safeastro:env/clientimports with schema defaults inastro.config.mjs(ADR-050).PUBLIC_CONTACT_CHAT_HOURSand the “Live Chat” contact method were removed —contactMethodsnow contains Email and Phone only — and the emoji icons were replaced by theIconatom (ADR-055), completing the “easy to replace with icon components later” intent noted below.
Problem: Hardcoded Contact Information
Section titled “Problem: Hardcoded Contact Information”Before: Contact details (email, phone, social links) were hardcoded in the template, creating security and maintenance issues.
Issues:
- Hardcoded
hello@example.comand+1234567890exposed in source - No per-environment configuration (dev vs production)
- Difficult to update across multiple locations
- Security risk: contact info in version control
Decision: Environment Variables for Contact Info
Section titled “Decision: Environment Variables for Contact Info”Implementation:
// contact.astro frontmatterconst contactEmail = import.meta.env.PUBLIC_CONTACT_EMAIL || "hello@example.com";const contactPhone = import.meta.env.PUBLIC_CONTACT_PHONE || "+1234567890";const socialGithub = import.meta.env.PUBLIC_SOCIAL_GITHUB || "https://github.com/example";Environment variables (.env.example):
# Contact InformationPUBLIC_CONTACT_EMAIL=hello@example.comPUBLIC_CONTACT_PHONE=+1234567890PUBLIC_CONTACT_PHONE_DISPLAY="+1 (234) 567-890"PUBLIC_CONTACT_LOCATION="San Francisco, CA"PUBLIC_CONTACT_TIMEZONE="Mon-Fri, 9AM-6PM PST"PUBLIC_CONTACT_CHAT_HOURS="Mon-Fri, 9AM-5PM EST"
# Social Media LinksPUBLIC_SOCIAL_GITHUB=https://github.com/examplePUBLIC_SOCIAL_LINKEDIN=https://linkedin.com/company/examplePUBLIC_SOCIAL_TWITTER=https://twitter.com/exampleBenefits:
- ✅ Per-environment configuration (staging, production)
- ✅ Single source of truth for contact info
- ✅ Fallback values for development
- ✅ Security: sensitive info not committed to git
Problem: Inline Emojis Anti-Pattern
Section titled “Problem: Inline Emojis Anti-Pattern”Before: Emojis scattered throughout markup without semantic labels.
<!-- ❌ Anti-pattern --><span class="text-2xl" aria-hidden="true">📧</span><span class="text-2xl" aria-hidden="true">💬</span><span class="text-2xl" aria-hidden="true">📞</span>Issues:
- No semantic meaning for screen readers
- Inconsistent with component-based architecture
- Difficult to maintain and update
- Violates accessibility best practices
Decision: Centralized Icon Data with Semantic Labels
Section titled “Decision: Centralized Icon Data with Semantic Labels”Implementation:
interface ContactMethod { icon: string; iconLabel: string; // Semantic label for accessibility title: string; description: string; // ...}
const contactMethods: ContactMethod[] = [ { icon: "📧", iconLabel: "Email icon", title: "Email", description: "For detailed inquiries or project discussions", // ... }, // ...];Usage:
<span class="text-2xl" aria-hidden="true" aria-label={method.iconLabel}> {method.icon}</span>Benefits:
- ✅ Semantic labels for accessibility
- ✅ Single source of truth for icons
- ✅ Easy to replace with icon components later
- ✅ Consistent with component-based patterns
Problem: Repetitive Card Markup
Section titled “Problem: Repetitive Card Markup”Before: Contact method cards duplicated 3 times with slight variations.
<!-- ❌ Repetitive --><Card class="p-6"> <div class="flex items-start space-x-4"> <div class="shrink-0 w-12 h-12 bg-surface-subtle rounded-lg flex items-center justify-center"> <span class="text-2xl" aria-hidden="true">📧</span> </div> <div class="flex-1"> <h3 class="text-lg font-semibold text-foreground-default mb-2">Email</h3> <p class="text-foreground-subtle mb-3"> For detailed inquiries or project discussions </p> <Button href="mailto:hello@example.com" variant="secondary" size="sm"> hello@example.com </Button> </div> </div></Card><!-- Repeated 2 more times with different content -->Issues:
- 60+ lines of duplicated markup
- Difficult to maintain consistency
- Error-prone when adding new contact methods
- Violates DRY principle
Decision: Declarative Data Structure with Array Mapping
Section titled “Decision: Declarative Data Structure with Array Mapping”Implementation:
interface ContactMethod { icon: string; iconLabel: string; title: string; description: string; action?: { href: string; label: string; }; badge?: { text: string; role: "status" | "presentation"; additionalInfo?: string; };}
const contactMethods: ContactMethod[] = [ { icon: "📧", iconLabel: "Email icon", title: "Email", description: "For detailed inquiries or project discussions", action: { href: `mailto:${contactEmail}`, label: contactEmail, }, }, { icon: "💬", iconLabel: "Chat icon", title: "Live Chat", description: "Quick questions? Chat with us in real-time", badge: { text: "Online now", role: "status", additionalInfo: contactChatHours, }, }, { icon: "📞", iconLabel: "Phone icon", title: "Phone", description: "Prefer to talk? Schedule a call with our team", action: { href: `tel:${contactPhone}`, label: contactPhoneDisplay, }, },];Usage:
<div class="space-y-6"> {contactMethods.map((method) => ( <Card class="p-6"> <div class="flex items-start space-x-4"> <div class="shrink-0 w-12 h-12 bg-surface-subtle rounded-lg flex items-center justify-center"> <span class="text-2xl" aria-hidden="true" aria-label={method.iconLabel}>{method.icon}</span> </div> <div class="flex-1"> <h3 class="text-lg font-semibold text-foreground-default mb-2">{method.title}</h3> <p class="text-foreground-subtle mb-3"> {method.description} </p> {method.action && ( <Button href={method.action.href} variant="secondary" size="sm"> {method.action.label} </Button> )} {method.badge && ( <div class="flex items-center space-x-2"> <Badge role={method.badge.role}>{method.badge.text}</Badge> {method.badge.additionalInfo && ( <span class="text-sm text-foreground-subtle"> {method.badge.additionalInfo} </span> )} </div> )} </div> </div> </Card> ))}</div>Benefits:
- ✅ Single source of truth for contact methods
- ✅ Easy to add/remove/reorder methods
- ✅ Type-safe with TypeScript interfaces
- ✅ Consistent rendering logic
- ✅ Reduced code duplication (60+ lines → 30 lines)
Additional Data Structures
Section titled “Additional Data Structures”Location & Availability:
interface LocationInfo { icon: string; iconLabel: string; text: string; badge: string;}
const locationInfo: LocationInfo[] = [ { icon: "📍", iconLabel: "Location pin", text: contactLocation, badge: "Remote-friendly", }, // ...];Response Expectations:
interface ResponseExpectation { icon: string; iconLabel: string; bgClass: string; title: string; description: string;}
const responseExpectations: ResponseExpectation[] = [ { icon: "⚡", iconLabel: "Lightning bolt", bgClass: "bg-surface-subtle", title: "Quick Response", description: "We'll acknowledge your message within 2 hours during business hours", }, // ...];Pattern for Future Extensions
Section titled “Pattern for Future Extensions”When adding new contact methods:
- Add data to
contactMethodsarray - No template changes needed
- Automatic rendering with consistent styling
Example - Adding Discord:
const contactMethods: ContactMethod[] = [ // ... existing methods { icon: "💬", iconLabel: "Discord icon", title: "Discord Community", description: "Join our community for real-time support", action: { href: "https://discord.gg/example", label: "Join Discord", }, },];References
Section titled “References”- Progressive Enhancement
- Module Scripts
- HTML5 Form Validation
- Defer vs Async
- Environment Variables in Astro
- TypeScript Interfaces
Enforcement
Section titled “Enforcement”- Not machine-checkable: that the form remains functional without JavaScript is exercised manually and in E2E review, not statically; script externalization is a review concern.
- Graduation log: (empty at creation; entries added when a check changes status)
Date: 2025-10-01 (footer backfilled 2026-07-05 from git history; this record predates the footer convention)
Participants: Template maintainers
Outcome: Accepted