Skip to content

ADR-021: Contact Form Progressive Enhancement

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)

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.

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:

  1. Should the form validation JavaScript load immediately?
  2. Can we defer the script without breaking functionality?
  3. What’s the performance impact of the inline script?

Extract form enhancement script to external module and defer loading.

  1. Extract to external module: ContactFormScript.ts
  2. Use inline <script> with import: Keeps script co-located with component
  3. Rely on browser’s module loading: Modern browsers defer module scripts automatically
  4. Progressive enhancement: Form works without JavaScript

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 imported
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initContactForm);
} else {
initContactForm();
}
}
  1. Automatic deferral: Module scripts are deferred by default (don’t block parsing)
  2. Better caching: External file cached separately from HTML
  3. Code splitting: Bundler can optimize module loading
  4. Maintainability: Easier to test and update
  5. Reusability: Can be imported by other forms if needed

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

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)
Initial HTML: 15KB
Inline script: 2.5KB (blocks parsing)
Total: 17.5KB
Parse time: +50ms (script blocks)
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

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)
  1. Without JavaScript:

    Terminal window
    # Disable JavaScript in DevTools
    # Verify form submits natively
    # Check HTML5 validation works
  2. With JavaScript:

    Terminal window
    # Enable JavaScript
    # Verify enhanced validation
    # Check loading states
    # Test error handling
  3. Network Throttling:

    Terminal window
    # Slow 3G in DevTools
    # Verify form usable before JS loads
    # Check progressive enhancement
Terminal window
# Playwright test
pnpm run test:e2e -- contact.spec.ts
# Performance audit
pnpm run perf:lighthouse
  • 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
  • Complexity: Slightly more files (1 additional TypeScript file)
  • Bundle size: Same total size (no reduction, just better loading)
  • No visual changes: Same UX for end users
  • No breaking changes: Form behavior identical

General pattern for all forms:

  1. HTML-first: Form works with native submission
  2. External script: Extract enhancement logic
  3. Module import: Use deferred module loading
  4. 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>

Amendment (2026-08-02): this section records the original implementation; the shipped contact page has since evolved. Env access moved from import.meta.env with || fallbacks to type-safe astro:env/client imports with schema defaults in astro.config.mjs (ADR-050). PUBLIC_CONTACT_CHAT_HOURS and the “Live Chat” contact method were removed — contactMethods now contains Email and Phone only — and the emoji icons were replaced by the Icon atom (ADR-055), completing the “easy to replace with icon components later” intent noted below.

Before: Contact details (email, phone, social links) were hardcoded in the template, creating security and maintenance issues.

Issues:

  • Hardcoded hello@example.com and +1234567890 exposed 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 frontmatter
const 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):

Terminal window
# Contact Information
PUBLIC_CONTACT_EMAIL=hello@example.com
PUBLIC_CONTACT_PHONE=+1234567890
PUBLIC_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 Links
PUBLIC_SOCIAL_GITHUB=https://github.com/example
PUBLIC_SOCIAL_LINKEDIN=https://linkedin.com/company/example
PUBLIC_SOCIAL_TWITTER=https://twitter.com/example

Benefits:

  • ✅ Per-environment configuration (staging, production)
  • ✅ Single source of truth for contact info
  • ✅ Fallback values for development
  • ✅ Security: sensitive info not committed to git

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

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)

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",
},
// ...
];

When adding new contact methods:

  1. Add data to contactMethods array
  2. No template changes needed
  3. 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",
},
},
];
  • 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