617 lines
19 KiB
Markdown
617 lines
19 KiB
Markdown
# 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 & 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 '.\|–\|—\|–' 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+
|