19 KiB
OPTIMIZATION - Mobile Responsive, SEO, Testing, and Performance
Author: Andre Cobham / Arising Media Updated: 2026-06-09
Mobile
Mobile-first CSS. Default styles target 320px and up.
Breakpoints: 360px, 480px, 600px, 768px, 900px, 1023px, 1024px.
Switch to mobile nav at max-width: 1023px, not 768px. A typical header does not fit cleanly below 1024px.
Inline grid styles (style="display:grid;grid-template-columns:1fr 1fr") require !important overrides in media queries to collapse on mobile. Include override block at end of main.css.
Always set: html, body { overflow-x: clip; max-width: 100%; }
Form fields (input, select, textarea) require min-width: 0 and box-sizing: border-box on mobile or they push layout wider than viewport.
Touch targets: 44x44px minimum (WCAG, Apple HIG).
Verification . Before Declaring Done
Always run a Playwright check at multiple viewport widths. Save the script in
.planning/mobile_check.py:
from playwright.sync_api import sync_playwright
PAGES = ['/', '/about/', '/services/', '/locations/buffalo.html', '/contact/']
with sync_playwright() as p:
b = p.firefox.launch(headless=True)
for w in [320, 360, 390, 768, 900, 1023, 1024, 1200]:
ctx = b.new_context(viewport={'width': w, 'height': 800})
page = ctx.new_page()
for path in PAGES:
page.goto(f'http://localhost:8096{path}', wait_until='networkidle')
r = page.evaluate('() => ({sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth})')
diff = r['sw'] - r['cw']
status = 'OK' if diff <= 0 else f'OVERFLOW +{diff}px'
print(f' w={w} {path:<35} {status}')
ctx.close()
b.close()
Result must be zero overflow on every page at every width.
Animation on Mobile
Scroll-triggered animations (data-animate="up" etc.) work the same on mobile.
But if you take a full-page screenshot for review, force-trigger them first:
page.evaluate("() => document.querySelectorAll('[data-animate]').forEach(e => e.classList.add('in-view'))")
page.wait_for_timeout(500)
Otherwise the screenshot shows blank sections that are actually hiding behind the IntersectionObserver.
Touch Targets
- Tap targets must be at least 44x44px (Apple HIG, WCAG)
- Header menu button: 44px square minimum
- Form submit buttons: padding 0.875rem vertical minimum
- Phone-link CTAs: same
Test Devices
When the site is "done", verify on:
- Real iPhone (Safari) . test the form actually submits
- Real Android phone (Chrome) . same
- Tablet (iPad) . header switches to mobile menu cleanly at 1023px and below
- Desktop (any browser) . full nav, hover states work
SEO, Meta, and Schema
Every page on every site must include the full set of head tags below. No exceptions.
Required <head> Tags (Every Page)
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="site-root" content="/">
<title>{Page-specific title under 60 chars} | {Brand}</title>
<meta name="description" content="{Page-specific description, 150-160 chars, no dashes}">
<link rel="canonical" href="https://{domain}{path}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://{domain}{path}">
<meta property="og:title" content="{Page title}">
<meta property="og:description" content="{Page description}">
<meta property="og:image" content="https://{domain}/assets/images/og-default.jpg">
<meta property="og:site_name" content="{Brand}">
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://{domain}{path}">
<meta name="twitter:title" content="{Page title}">
<meta name="twitter:description" content="{Page description}">
<meta name="twitter:image" content="https://{domain}/assets/images/og-default.jpg">
<!-- Robots -->
<meta name="robots" content="index, follow">
<!-- Theme color (mobile browsers) -->
<meta name="theme-color" content="#{brand-color-hex}">
<!-- Favicon set -->
<link rel="icon" type="image/svg+xml" href="/assets/images/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32.png">
<link rel="apple-touch-icon" href="/assets/images/apple-touch-icon.png">
<!-- Fonts (preconnect + load) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Stylesheets -->
<link rel="stylesheet" href="/assets/css/main.css">
<link rel="stylesheet" href="/assets/css/components.css">
<!-- Schema.org JSON-LD (page-specific, see below) -->
<script type="application/ld+json">{...}</script>
</head>
OG Image
One default OG image at /assets/images/og-default.jpg:
- 1200x630px
- Brand colors
- Logo + company name + city + one descriptor (e.g., "Hardwood Floor Refinishing . Buffalo, NY")
- Under 200KB optimized
For pages with their own hero image (location, service detail), use that image's webp/jpg version as the OG image instead of the default.
Schema.org JSON-LD
Home Page . LocalBusiness
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"@id": "https://{domain}/#business",
"name": "{Legal business name}",
"url": "https://{domain}",
"telephone": "+1{10digits}",
"email": "{contact email}",
"address": {
"@type": "PostalAddress",
"streetAddress": "{street}",
"addressLocality": "{city}",
"addressRegion": "{state}",
"postalCode": "{zip}",
"addressCountry": "US"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": {lat},
"longitude": {lng}
},
"areaServed": [
{ "@type": "City", "name": "Buffalo" },
{ "@type": "City", "name": "Amherst" }
],
"openingHoursSpecification": [{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],
"opens": "08:00",
"closes": "17:00"
}],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.9",
"reviewCount": "47"
}
}
Service Detail Pages . Service
{
"@context": "https://schema.org",
"@type": "Service",
"serviceType": "{Service name}",
"provider": { "@id": "https://{domain}/#business" },
"areaServed": { "@type": "AdministrativeArea", "name": "Erie County, NY" },
"url": "https://{domain}{path}"
}
Location Pages . LocalBusiness with areaServed Override
Same as the home LocalBusiness schema but override areaServed to the
specific city, and include a different @id per page.
Every Page . BreadcrumbList
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://{domain}/" },
{ "@type": "ListItem", "position": 2, "name": "Services", "item": "https://{domain}/services/" },
{ "@type": "ListItem", "position": 3, "name": "Floor Refinishing" }
]
}
FAQ Pages or Sections . FAQPage
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [{
"@type": "Question",
"name": "How long does refinishing take?",
"acceptedAnswer": { "@type": "Answer", "text": "Most rooms take 2 to 3 days..." }
}]
}
robots.txt
Every site needs one at /robots.txt:
User-agent: *
Allow: /
Disallow: /api/
Sitemap: https://{domain}/sitemap.xml
sitemap.xml
Generate at the end of every build. Save at /sitemap.xml. One <url> entry
per page. Use <lastmod> from the file's mtime.
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://{domain}/</loc>
<lastmod>2026-05-08</lastmod>
<priority>1.0</priority>
</url>
<url>
<loc>https://{domain}/services/floor-refinishing.html</loc>
<lastmod>2026-05-08</lastmod>
<priority>0.8</priority>
</url>
</urlset>
After deploy, submit the sitemap to Google Search Console.
Title and Description Rules
- Title under 60 characters. Format:
{Service} | {Brand} . {City}, {State} - Description 150-160 characters, action-oriented, include city + service, phone when possible
- Never use the same title/description on multiple pages
- City + service name in title for location and service pages (huge SEO impact)
- No em-dashes in meta tags. Use
|pipe as brand separator - Always include
<meta name="keywords">. 5-10 comma-separated terms, city + service variants
Title/Meta Examples (Lahrcarpetcleaning.com Reference)
Homepage:
<title>Lahr Carpet Cleaning | Residential & Commercial Carpet Cleaning . Finger Lakes, NY</title>
<meta name="description" content="Professional carpet and upholstery cleaning for homes and businesses across the Finger Lakes region. Serving Waterloo, Geneva, Seneca Falls and surrounding areas. Book a free estimate today.">
<meta name="keywords" content="carpet cleaning Waterloo NY, carpet cleaning Geneva NY, Finger Lakes carpet cleaning, upholstery cleaning, commercial carpet cleaning, stain removal Seneca Falls">
<link rel="canonical" href="https://lahrcarpetcleaning.com/">
Service page:
<title>Carpet Cleaning | Lahr Carpet Cleaning . Waterloo, NY</title>
<meta name="description" content="Professional carpet cleaning in Waterloo and the Finger Lakes region. Deep steam cleaning removes stains, odors, and allergens. Residential and commercial. Call 315-719-1218.">
<link rel="canonical" href="https://lahrcarpetcleaning.com/services/carpet-cleaning/">
Location page:
<title>Carpet Cleaning in Seneca Falls, NY | Lahr Carpet Cleaning</title>
<meta name="description" content="Professional carpet and upholstery cleaning in Seneca Falls, NY. Lahr Carpet Cleaning serves homes and businesses in Seneca Falls and the surrounding Finger Lakes area.">
<link rel="canonical" href="https://lahrcarpetcleaning.com/locations/seneca-falls-ny/">
Audit Before Launch
# All pages have title
grep -L '<title>' site/**/*.html
# All pages have canonical
grep -L '<link rel="canonical"' site/**/*.html
# All pages have og: tags
for f in $(find site -name "*.html"); do
grep -q 'property="og:' "$f" || echo "MISSING og: $f"
done
# All pages have JSON-LD
for f in $(find site -name "*.html"); do
grep -q 'application/ld+json' "$f" || echo "MISSING schema: $f"
done
Each command should return zero results before declaring done.
llms.txt . AI Crawler Documentation
Every project must include /llms.txt at the site root. This is the emerging standard (llmstxt.org) for telling AI crawlers (Perplexity, Claude, GPT) what your site does and what they should/should not index.
Location: src/llms.txt (Docker) or public_html/llms.txt (cPanel)
Minimum content:
# {Brand Name}
> {One-line description of what the site does}
{2-3 sentence description of the business, services, and target audience.}
## Services
- {Service 1}
- {Service 2}
## Pages
- /: Homepage
- /about/: About
- /contact/: Contact
## Not for external use
- /api/: Internal API endpoints
- /account/: User account pages
robots.txt must also disallow /api/ and private paths . llms.txt is the invitation, robots.txt is the boundary.
Testing and Verification
Before declaring a site done, run every check in this document and show the output. No exceptions.
The Law
Before stating ANYTHING works, is configured, is ready, is installed, or is complete . run a live test and show the raw output. No output = it was not tested. Not for plugins, not for logins, not for deploys. Test it. Show the proof.
Build Verification
After running build scripts:
# Zero unreplaced placeholders
grep -rn "{{" site/locations/*.html site/services/*.html
# Result: empty
Container Health
docker compose ps
# All services "Up" and (healthy) where applicable
docker logs {project}-api-1 2>&1 | tail -20
# No errors, no stack traces
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:{port}/
# 200
URL Surface Check
Every public URL returns 200. Every sensitive URL returns 404.
# Public . should all be 200
for p in "/" "/about/" "/services/" "/locations/" "/locations/buffalo.html" \
"/services/floor-refinishing.html" "/contact/" "/reviews/" \
"/assets/css/main.css" "/components/header.html" "/api/health"; do
curl -s -o /dev/null -w "%{http_code} ${p}\n" http://localhost:{port}${p}
done
# Sensitive . should all be 404
for p in "/Dockerfile" "/nginx.conf" "/.env" "/api/.env" "/.git/HEAD" \
"/build_locations.py" "/docker-compose.yml" "/README.md"; do
curl -s -o /dev/null -w "%{http_code} ${p}\n" http://localhost:{port}${p}
done
Container Content Check
Confirm sensitive files are not inside the nginx container:
docker exec {project}-web-1 ls /usr/share/nginx/html/
# Only public folders + index.html. No api/, no Dockerfile, no .env
docker exec {project}-web-1 ls /usr/share/nginx/html/api/ 2>&1
# Result: "No such file or directory" (api was correctly excluded from web image)
Mobile Responsive Check (Playwright)
from playwright.sync_api import sync_playwright
PAGES = ['/', '/about/', '/services/', '/services/floor-refinishing.html',
'/locations/', '/locations/buffalo.html', '/contact/', '/reviews/']
with sync_playwright() as p:
b = p.firefox.launch(headless=True)
fails = 0
for w in [320, 360, 390, 768, 900, 1023, 1024, 1200]:
ctx = b.new_context(viewport={'width': w, 'height': 800})
page = ctx.new_page()
for path in PAGES:
page.goto(f'http://localhost:{port}{path}', wait_until='networkidle')
r = page.evaluate('() => ({sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth})')
diff = r['sw'] - r['cw']
status = 'OK' if diff <= 0 else f'OVERFLOW +{diff}px'
print(f' w={w} {path:<40} {status}')
if diff > 0: fails += 1
ctx.close()
print(f'\nfails: {fails}')
b.close()
Result must be fails: 0 across every page at every width.
Form Submission End-to-End
After Resend domain is verified:
# Validation rejection test
curl -s -X POST http://localhost:{port}/api/estimate \
-H "Content-Type: application/json" \
-d '{"name":"","email":"bad"}'
# Expected: {"error":"Validation failed.","fields":[...]}
# Real submission test
curl -s -X POST http://localhost:{port}/api/estimate \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"acobham@arisingmedia.us","phone":"(716) 555-1234","address":"100 Test St","city":"Buffalo","zip":"14201","service":"refinishing","message":"E2E test","token":""}'
# Expected: {"ok":true}
# Verify the email actually arrived in the destination inbox
Idempotency Test
Send the same payload twice. Both should return {"ok":true}. Inbox should
receive only ONE email (Resend deduplicates via Idempotency-Key).
Rate Limit Test
Send 6 requests rapidly from the same IP:
for i in 1 2 3 4 5 6; do
curl -s -X POST http://localhost:{port}/api/estimate \
-H "Content-Type: application/json" \
-d "{\"name\":\"RL$i\",\"email\":\"a@b.co\",\"phone\":\"(716) 555-1234\",\"address\":\"x\",\"service\":\"x\",\"token\":\"\"}"
echo ""
done
# Requests 1-5: 200 or 422
# Request 6: 429 Too Many Requests
SEO Surface
# Every page has <title>
grep -L "<title>" site/**/*.html
# Result: empty
# Every page has canonical
grep -L 'rel="canonical"' site/**/*.html
# Every page has og:title
for f in $(find site -name "*.html"); do
grep -q 'property="og:title"' "$f" || echo "MISSING og: $f"
done
# robots.txt and sitemap.xml exist and are served
curl -s http://localhost:{port}/robots.txt | head -5
curl -s http://localhost:{port}/sitemap.xml | head -10
Dash Check (Content Rule)
grep -rn '.\|–\|—\|–' site/ --include="*.html" --include="*.json"
# Result: empty
Lighthouse / PageSpeed (Recommended)
Run https://pagespeed.web.dev/ on the live URL after launch. Targets:
- Performance: 90+
- Accessibility: 95+
- Best Practices: 95+
- SEO: 100
If accessibility is below 95, common fixes:
- Color contrast on body text (WCAG AA = 4.5:1 for normal text)
- Form labels associated with inputs (
<label for="...">) - Alt text on every meaningful image
- Skip-to-main-content link
- Focus indicators not removed in CSS
Browser Test Matrix
Test in:
- Firefox (current) . desktop + mobile emulation
- Chrome (current) . desktop + mobile emulation
- Safari (current) . desktop + iOS emulator if possible
- Real iPhone . actual phone, actual Safari
- Real Android . actual phone, actual Chrome
The form must actually submit and produce a real email on the real phones. That's the launch gate.
Pre-Launch Sign-Off
Don't ship until ALL of these are green:
- All public URLs return 200
- All sensitive URLs return 404
- No sensitive files inside nginx container
- Zero mobile horizontal overflow at 320-1440px
- Form submission produces a real email
- Idempotency dedupe works (same payload twice = one email)
- Rate limit triggers at 6th request
- All pages have title, description, canonical, og:, schema JSON-LD
- robots.txt and sitemap.xml exist and are accessible
- Zero em-dashes anywhere in HTML or JSON
- Resend domain shows fully green (SPF + DKIM + DMARC)
- First test email lands in primary inbox, not spam
- Tested on real iPhone and real Android device
- Lighthouse score 90+ across all four categories
Performance Standards
Images
- Must be WebP, converted via
convert-to-webp.py - Service cards / thumbnails: max 900px wide, 78% quality, 30–80KB target
- Hero images: max 1400px wide, 80% quality, 50–180KB target
- OG images: max 1200px wide, 85% quality, under 150KB
Videos
- Hero video: mp4 + webm pair
- Max ~5MB per clip
- Stitched reels: concatenated via ffmpeg
Cache Headers
- Static assets (jpg, png, webp, css, js, svg, woff, woff2, mp4, webm):
Cache-Control: public, immutable; expires 30d - HTML pages:
Cache-Control: no-cache, must-revalidate(or vary by deployment) - API responses:
Cache-Control: no-store
JavaScript
- No render-blocking JS (use
deferortype="module") - Vanilla JS only, no frameworks
fetch,IntersectionObserver,querySelector
CSS
- No unused CSS
- Plain CSS only, no Sass/Tailwind
tokens.css(design tokens) +main.css(components)- Inline
<style>blocks only for critical above-the-fold styles
HTML
- One
<h1>per page - Semantic tags:
<main>,<section>,<article>,<aside>,<header>,<footer>,<nav> - Every
<img>hasalt,width,height, andloading="lazy"(except hero) - No inline event handlers (onclick, onload, etc.)
- defer or async on all non-critical
<script>tags
Lighthouse Targets
- Performance: 90+
- Accessibility: 95+
- Best Practices: 95+
- SEO: 100
Minimum viable scores for launch:
- Performance: 80+ (mobile), 90+ (desktop)
- Accessibility: 90+
- Best Practices: 90+
- SEO: 95+