recent updates

This commit is contained in:
2026-06-09 18:31:59 +02:00
parent 398b94965c
commit 94f7a1f72a
42 changed files with 8686 additions and 0 deletions
+616
View File
@@ -0,0 +1,616 @@
# 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+