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

617 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```python
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:
```python
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)
```html
<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
```json
{
"@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
```json
{
"@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
```json
{
"@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
```json
{
"@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
<?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:
```html
<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:
```html
<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:
```html
<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
```bash
# 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:
```bash
# Zero unreplaced placeholders
grep -rn "{{" site/locations/*.html site/services/*.html
# Result: empty
```
### Container Health
```bash
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.
```bash
# 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:
```bash
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)
```python
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:
```bash
# 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:
```bash
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
```bash
# 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)
```bash
grep -rn '.\|\|&mdash;\|&ndash;' 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, 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+