Files
arisingmedia-web-sops/OPTIMIZATION.md
T
2026-06-09 18:31:59 +02:00

19 KiB
Raw Blame History

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 &amp; 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 '.\|\|&mdash;\|&ndash;' site/ --include="*.html" --include="*.json"
# Result: empty

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, 3080KB target
  • Hero images: max 1400px wide, 80% quality, 50180KB 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 defer or type="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> has alt, width, height, and loading="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+