Polish accessibility and production readiness

This commit is contained in:
2026-08-18 00:54:33 -04:00
parent e008b3b70f
commit 43ec304783
12 changed files with 285 additions and 36 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
export default { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
export default config;
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default { const config = {
plugins: ["prettier-plugin-tailwindcss"], plugins: ["prettier-plugin-tailwindcss"],
}; };
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

+81 -16
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { z } from "zod"; import { z } from "zod";
const empty = { name: "", email: "", company: "", message: "", website: "" }; const empty = { name: "", email: "", company: "", message: "", website: "" };
@@ -18,6 +18,7 @@ const contactSchema = z.object({
}); });
export function ContactForm() { export function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState(empty); const [form, setForm] = useState(empty);
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const [isSuccess, setIsSuccess] = useState(false); const [isSuccess, setIsSuccess] = useState(false);
@@ -31,6 +32,21 @@ export function ContactForm() {
setForm((f) => ({ ...f, [field]: e.target.value })); setForm((f) => ({ ...f, [field]: e.target.value }));
} }
function focusFirstError(errors: typeof fieldErrors) {
const fields: Array<keyof typeof empty> = [
"name",
"email",
"company",
"message",
];
const firstInvalid = fields.find((field) => errors[field]);
const control = firstInvalid
? formRef.current?.elements.namedItem(firstInvalid)
: null;
if (control instanceof HTMLElement) control.focus();
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) { async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setServerError(null); setServerError(null);
@@ -38,13 +54,15 @@ export function ContactForm() {
const parsed = contactSchema.safeParse(form); const parsed = contactSchema.safeParse(form);
if (!parsed.success) { if (!parsed.success) {
const flat = parsed.error.flatten().fieldErrors; const flat = parsed.error.flatten().fieldErrors;
setFieldErrors({ const errors = {
name: flat.name?.[0], name: flat.name?.[0],
email: flat.email?.[0], email: flat.email?.[0],
company: flat.company?.[0], company: flat.company?.[0],
message: flat.message?.[0], message: flat.message?.[0],
website: flat.website?.[0], website: flat.website?.[0],
}); };
setFieldErrors(errors);
focusFirstError(errors);
return; return;
} }
@@ -68,13 +86,15 @@ export function ContactForm() {
if (!res.ok || !data.ok) { if (!res.ok || !data.ok) {
if ("fieldErrors" in data && data.fieldErrors) { if ("fieldErrors" in data && data.fieldErrors) {
setFieldErrors({ const errors = {
name: data.fieldErrors.name?.[0], name: data.fieldErrors.name?.[0],
email: data.fieldErrors.email?.[0], email: data.fieldErrors.email?.[0],
company: data.fieldErrors.company?.[0], company: data.fieldErrors.company?.[0],
message: data.fieldErrors.message?.[0], message: data.fieldErrors.message?.[0],
website: data.fieldErrors.website?.[0], website: data.fieldErrors.website?.[0],
}); };
setFieldErrors(errors);
focusFirstError(errors);
} }
setServerError( setServerError(
("message" in data ? data.message : undefined) ?? ("message" in data ? data.message : undefined) ??
@@ -94,7 +114,11 @@ export function ContactForm() {
if (isSuccess) { if (isSuccess) {
return ( return (
<div className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center"> <div
role="status"
aria-live="polite"
className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center"
>
<div className="border-accent-border bg-accent-bg text-accent mx-auto flex h-12 w-12 items-center justify-center rounded-full border"> <div className="border-accent-border bg-accent-bg text-accent mx-auto flex h-12 w-12 items-center justify-center rounded-full border">
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6"> <svg viewBox="0 0 24 24" fill="none" className="h-6 w-6">
<path <path
@@ -110,9 +134,8 @@ export function ContactForm() {
Message received. Message received.
</h3> </h3>
<p className="text-muted mx-auto mt-2 max-w-sm text-sm"> <p className="text-muted mx-auto mt-2 max-w-sm text-sm">
Thank you for reaching out. I will get back to you shortly. This Thank you for reaching out. Your message went directly to Hadlock, and
message went straight to Hadlock. No third-party form service handled I will get back to you shortly.
it.
</p> </p>
<button <button
type="button" type="button"
@@ -131,39 +154,63 @@ export function ContactForm() {
return ( return (
<form <form
ref={formRef}
onSubmit={onSubmit} onSubmit={onSubmit}
className="grid gap-5 text-left sm:grid-cols-2" className="grid gap-5 text-left sm:grid-cols-2"
noValidate noValidate
> >
<Field label="Name" error={fieldErrors?.name?.[0]}> <Field label="Name" error={fieldErrors.name} errorId="contact-name-error">
<input <input
name="name"
type="text" type="text"
autoComplete="name" autoComplete="name"
value={form.name} value={form.name}
onChange={update("name")} onChange={update("name")}
aria-invalid={!!fieldErrors.name}
aria-describedby={fieldErrors.name ? "contact-name-error" : undefined}
className={inputClass} className={inputClass}
placeholder="Your name" placeholder="Your name"
/> />
</Field> </Field>
<Field label="Email" error={fieldErrors?.email?.[0]}> <Field
label="Email"
error={fieldErrors.email}
errorId="contact-email-error"
>
<input <input
name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
spellCheck={false}
value={form.email} value={form.email}
onChange={update("email")} onChange={update("email")}
aria-invalid={!!fieldErrors.email}
aria-describedby={
fieldErrors.email ? "contact-email-error" : undefined
}
className={inputClass} className={inputClass}
placeholder="you@company.com" placeholder="you@company.com"
/> />
</Field> </Field>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Field label="Company" optional error={fieldErrors?.company?.[0]}> <Field
label="Company"
optional
error={fieldErrors.company}
errorId="contact-company-error"
>
<input <input
name="company"
type="text" type="text"
autoComplete="organization" autoComplete="organization"
value={form.company} value={form.company}
onChange={update("company")} onChange={update("company")}
aria-invalid={!!fieldErrors.company}
aria-describedby={
fieldErrors.company ? "contact-company-error" : undefined
}
className={inputClass} className={inputClass}
placeholder="Where you work (optional)" placeholder="Where you work (optional)"
/> />
@@ -171,11 +218,20 @@ export function ContactForm() {
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Field label="What do you need?" error={fieldErrors?.message?.[0]}> <Field
label="What do you need?"
error={fieldErrors.message}
errorId="contact-message-error"
>
<textarea <textarea
name="message"
rows={5} rows={5}
value={form.message} value={form.message}
onChange={update("message")} onChange={update("message")}
aria-invalid={!!fieldErrors.message}
aria-describedby={
fieldErrors.message ? "contact-message-error" : undefined
}
className={`${inputClass} resize-y`} className={`${inputClass} resize-y`}
placeholder="Networks, servers, a website, a store, or something that does not exist yet. Tell me what you run and where it hurts." placeholder="Networks, servers, a website, a store, or something that does not exist yet. Tell me what you run and where it hurts."
/> />
@@ -187,6 +243,7 @@ export function ContactForm() {
<label> <label>
Website Website
<input <input
name="website"
type="text" type="text"
tabIndex={-1} tabIndex={-1}
autoComplete="off" autoComplete="off"
@@ -202,9 +259,13 @@ export function ContactForm() {
disabled={isPending} disabled={isPending}
className="bg-accent text-on-accent hover:bg-accent-hover rounded-md px-7 py-3.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60" className="bg-accent text-on-accent hover:bg-accent-hover rounded-md px-7 py-3.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60"
> >
{isPending ? "Sending..." : "Send message"} {isPending ? "Sending" : "Send message"}
</button> </button>
{serverError && <p className="text-sm text-red-400">{serverError}</p>} {serverError && (
<p role="alert" className="text-sm text-red-400">
{serverError}
</p>
)}
</div> </div>
</form> </form>
); );
@@ -217,11 +278,13 @@ function Field({
label, label,
optional, optional,
error, error,
errorId,
children, children,
}: { }: {
label: string; label: string;
optional?: boolean; optional?: boolean;
error?: string; error?: string;
errorId?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
@@ -234,7 +297,9 @@ function Field({
</span> </span>
{children} {children}
{error && ( {error && (
<span className="mt-1.5 block text-xs text-red-400">{error}</span> <span id={errorId} className="mt-1.5 block text-xs text-red-400">
{error}
</span>
)} )}
</label> </label>
); );
+23 -5
View File
@@ -19,6 +19,17 @@ export function MobileNav() {
setIsMounted(true); setIsMounted(true);
}, []); }, []);
useEffect(() => {
if (!isOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [isOpen]);
return ( return (
<> <>
<button <button
@@ -37,21 +48,28 @@ export function MobileNav() {
</button> </button>
{isMounted && {isMounted &&
isOpen &&
createPortal( createPortal(
<div <button
className={`bg-bg/60 fixed inset-0 z-40 backdrop-blur-md transition-opacity duration-200 md:hidden ${isOpen ? "opacity-100" : "pointer-events-none opacity-0"}`} type="button"
aria-label="Close menu"
className="bg-bg/60 fixed inset-0 z-40 backdrop-blur-md md:hidden"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
aria-hidden="true"
/>, />,
document.body, document.body,
)} )}
<div <div
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-all duration-300 md:hidden ${ inert={!isOpen}
aria-hidden={!isOpen}
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-[max-height,opacity] duration-300 md:hidden ${
isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0" isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0"
} overflow-hidden`} } overflow-hidden`}
> >
<nav className="flex flex-col gap-1 px-4 py-4"> <nav
aria-label="Mobile navigation"
className="flex flex-col gap-1 px-4 py-4"
>
{links.map((link) => ( {links.map((link) => (
<a <a
key={link.href} key={link.href}
+77
View File
@@ -3,6 +3,17 @@ import { z } from "zod";
import { sendContactEmail } from "~/server/mail"; import { sendContactEmail } from "~/server/mail";
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
const RATE_LIMIT_MAX_REQUESTS = 5;
const RATE_LIMIT_MAX_ENTRIES = 10_000;
type RateLimitEntry = {
count: number;
resetAt: number;
};
const rateLimits = new Map<string, RateLimitEntry>();
const contactSchema = z.object({ const contactSchema = z.object({
name: z.string().trim().min(1, "Please enter your name").max(200), name: z.string().trim().min(1, "Please enter your name").max(200),
email: z.string().trim().email("Please enter a valid email").max(320), email: z.string().trim().email("Please enter a valid email").max(320),
@@ -16,7 +27,73 @@ const contactSchema = z.object({
website: z.string().optional(), website: z.string().optional(),
}); });
function getClientIdentifier(req: Request) {
const forwardedFor = req.headers
.get("x-forwarded-for")
?.split(",")[0]
?.trim();
return (
req.headers.get("cf-connecting-ip") ??
forwardedFor ??
req.headers.get("x-real-ip") ??
"unknown"
);
}
function checkRateLimit(identifier: string) {
const now = Date.now();
if (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
for (const [key, entry] of rateLimits) {
if (entry.resetAt <= now) rateLimits.delete(key);
}
while (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
const oldestKey = rateLimits.keys().next().value;
if (!oldestKey) break;
rateLimits.delete(oldestKey);
}
}
const existing = rateLimits.get(identifier);
if (!existing || existing.resetAt <= now) {
rateLimits.set(identifier, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return null;
}
if (existing.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.ceil((existing.resetAt - now) / 1000);
}
existing.count += 1;
return null;
}
export async function POST(req: Request) { export async function POST(req: Request) {
const retryAfter = checkRateLimit(getClientIdentifier(req));
if (retryAfter !== null) {
return NextResponse.json(
{
ok: false,
message:
"Too many messages sent. Please wait a few minutes and try again.",
},
{
status: 429,
headers: {
"Cache-Control": "no-store",
"Retry-After": String(retryAfter),
},
},
);
}
const body: unknown = await req.json().catch(() => null); const body: unknown = await req.json().catch(() => null);
const parsed = contactSchema.safeParse(body); const parsed = contactSchema.safeParse(body);
+26 -1
View File
@@ -1,6 +1,6 @@
import "~/styles/globals.css"; import "~/styles/globals.css";
import { type Metadata } from "next"; import { type Metadata, type Viewport } from "next";
import { import {
Geist, Geist,
Geist_Mono, Geist_Mono,
@@ -15,6 +15,9 @@ import { env } from "~/env";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://hadlock.tech"), metadataBase: new URL("https://hadlock.tech"),
alternates: {
canonical: "/",
},
icons: { icons: {
icon: "/branding/icon_blue.svg", icon: "/branding/icon_blue.svg",
}, },
@@ -43,7 +46,29 @@ export const metadata: Metadata = {
url: "https://hadlock.tech", url: "https://hadlock.tech",
siteName: "Hadlock Technologies", siteName: "Hadlock Technologies",
type: "website", type: "website",
images: [
{
url: "/hadlock-pond.jpg",
width: 4032,
height: 3024,
alt: "Hadlock Pond at dawn in Fort Ann, New York",
},
],
}, },
twitter: {
card: "summary_large_image",
title: "Hadlock Technologies — Everything technical, handled",
description:
"One accountable technical partner for networks, servers, cloud, databases, web, and commerce.",
images: ["/hadlock-pond.jpg"],
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4f1ea" },
{ media: "(prefers-color-scheme: dark)", color: "#060a10" },
],
}; };
const umamiEnabled = const umamiEnabled =
+35 -12
View File
@@ -41,8 +41,14 @@ const services = [
export default function Home() { export default function Home() {
return ( return (
<div className="relative min-h-screen overflow-x-clip"> <div className="relative min-h-screen overflow-x-clip">
<a
href="#main-content"
className="bg-bg text-text focus-visible:ring-accent sr-only z-[100] rounded-md px-4 py-3 focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus-visible:ring-2"
>
Skip to content
</a>
<Header /> <Header />
<main> <main id="main-content">
<Hero /> <Hero />
<Services /> <Services />
<Approach /> <Approach />
@@ -58,14 +64,18 @@ export default function Home() {
function Wordmark() { function Wordmark() {
return ( return (
<Link href="/" className="group flex shrink-0 items-center"> <Link href="/" className="group flex shrink-0 items-center">
<img <Image
src="/branding/logo_white.svg" src="/branding/logo_white.svg"
alt="Hadlock Technologies" alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75" className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75"
/> />
<img <Image
src="/branding/logo_blue.svg" src="/branding/logo_blue.svg"
alt="Hadlock Technologies" alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75" className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75"
/> />
</Link> </Link>
@@ -139,14 +149,18 @@ function Hero() {
<div className="from-hero-overlay-start via-hero-overlay-mid to-hero-overlay-end absolute inset-0 -z-10 bg-gradient-to-r" /> <div className="from-hero-overlay-start via-hero-overlay-mid to-hero-overlay-end absolute inset-0 -z-10 bg-gradient-to-r" />
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col items-start justify-center px-6 pt-32 pb-16"> <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col items-start justify-center px-6 pt-32 pb-16">
<img <Image
src="/branding/logo_white.svg" src="/branding/logo_white.svg"
alt="Hadlock Technologies" alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-dark h-24 w-auto" className="theme-logo-dark h-24 w-auto"
/> />
<img <Image
src="/branding/logo_blue.svg" src="/branding/logo_blue.svg"
alt="Hadlock Technologies" alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-light h-24 w-auto" className="theme-logo-light h-24 w-auto"
/> />
@@ -378,20 +392,27 @@ function Projects() {
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24"> <div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
<div> <div>
<img <Image
src="/logo_white.svg" src="/logo_white.svg"
alt="Racetix" alt="Racetix"
width={1614}
height={304}
className="theme-logo-dark h-8 w-auto" className="theme-logo-dark h-8 w-auto"
/> />
<img <Image
src="/logo_red.svg" src="/logo_red.svg"
alt="Racetix" alt="Racetix"
width={1614}
height={304}
className="theme-logo-light h-8 w-auto" className="theme-logo-light h-8 w-auto"
/> />
<h3 className="text-text font-display mt-6 max-w-xl text-3xl font-bold tracking-tight uppercase sm:text-4xl">
Ticketing built for short tracks.
</h3>
<p className="text-text-secondary font-display mt-6 max-w-xl text-base leading-relaxed"> <p className="text-text-secondary font-display mt-6 max-w-xl text-base leading-relaxed">
Multi-tenant ticketing for short tracks. It owns ticket products, Racetix owns ticket products, orders, payments, admission
orders, payments, admission redemptions, and ticket email. Tracks redemptions, and ticket email. Tracks embed checkout in their own
embed checkout in their own site through the versioned API. site through the versioned API.
</p> </p>
<span className="text-racetix-red font-display mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70"> <span className="text-racetix-red font-display mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
Explore Racetix Explore Racetix
@@ -508,9 +529,11 @@ function Projects() {
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24"> <div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
<div> <div>
<img <Image
src="/racehub/logo_white.svg" src="/racehub/logo_white.svg"
alt="Riverhead Raceway" alt="Riverhead Raceway"
width={593}
height={122}
className="h-9 w-auto" className="h-9 w-auto"
/> />
<h3 className="font-racehub mt-6 max-w-xl text-3xl font-bold tracking-tight text-white uppercase sm:text-4xl"> <h3 className="font-racehub mt-6 max-w-xl text-3xl font-bold tracking-tight text-white uppercase sm:text-4xl">
@@ -519,7 +542,7 @@ function Projects() {
<p className="font-racehub mt-4 max-w-xl text-base leading-relaxed text-white/80"> <p className="font-racehub mt-4 max-w-xl text-base leading-relaxed text-white/80">
RaceHub is the website and CMS for Long Island&apos;s only auto RaceHub is the website and CMS for Long Island&apos;s only auto
racing venue. It publishes events, news, standings, and results, racing venue. It publishes events, news, standings, and results,
with live 2026 race data served from the Hotlap API. with live race data served from the Hotlap API.
</p> </p>
<span className="text-racehub-red font-racehub mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70"> <span className="text-racehub-red font-racehub mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
Visit Riverhead Raceway Visit Riverhead Raceway
+11
View File
@@ -0,0 +1,11 @@
import { type MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://hadlock.tech/sitemap.xml",
};
}
+11
View File
@@ -0,0 +1,11 @@
import { type MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://hadlock.tech",
changeFrequency: "monthly",
priority: 1,
},
];
}
+15
View File
@@ -172,6 +172,11 @@ body {
color: #ffffff; color: #ffffff;
} }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* Faint technical grid used behind the hero */ /* Faint technical grid used behind the hero */
.bg-grid { .bg-grid {
background-image: background-image:
@@ -218,6 +223,16 @@ body {
} }
} }
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.beenvoice-blob {
animation: none;
}
}
.racehub-checkered { .racehub-checkered {
background-color: var(--racehub-base); background-color: var(--racehub-base);
background-image: url("/racehub/checkered.svg"); background-image: url("/racehub/checkered.svg");