# 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 `` Tags (Every Page) ```html {Page-specific title under 60 chars} | {Brand} ``` ### 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 `` entry per page. Use `` from the file's mtime. ```xml https://{domain}/ 2026-05-08 1.0 https://{domain}/services/floor-refinishing.html 2026-05-08 0.8 ``` 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 `` . 5-10 comma-separated terms, city + service variants ### Title/Meta Examples (Lahrcarpetcleaning.com Reference) Homepage: ```html Lahr Carpet Cleaning | Residential & Commercial Carpet Cleaning . Finger Lakes, NY ``` Service page: ```html Carpet Cleaning | Lahr Carpet Cleaning . Waterloo, NY ``` Location page: ```html Carpet Cleaning in Seneca Falls, NY | Lahr Carpet Cleaning ``` ### Audit Before Launch ```bash # All pages have title grep -L '' 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 '.\|–\|—\|–' 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 `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+