# sops

number: 00
filename: 00-stack-philosophy.md
title: Arising Media's AI Development Stack Philosophy
updated_at: 2026-05-24T14:17:08.821257
full_content:
# Arising Media's AI Development Stack Philosophy
Developed by Andre Cobham and Claude

---

## The one stack

Every Arising Media site is built on the programmatic stack: PHP Router + standalone SQLite databases + PHP templates + data-driven components. There is no "pick a stack based on page count." All new sites and all migrations use this approach.

Stack B (static HTML) is legacy. Existing static sites (e.g., `lahrcarpetcleaning.com`) are maintained as-is until migrated. No new sites are started as static HTML.

## The programmatic stack

- **PHP Router** — `src/api/router.php` dispatches every URL to the correct PHP template. Edit one template = entire page class updates on next request.
- **PHP templates** — thin shells. They query the database, call `_header.php`, loop through sections, call `_footer.php`. No hardcoded copy ever lives in a PHP file.
- **Data components** — sections are typed JSON objects stored in `sections_json` on each page row. Templates loop through sections and render each type via a switch block. Content is in the database; structure is in the template.
- **Standalone SQLite databases** — one SQLite file per content domain, never one monolithic DB with all tables. Examples:
  - `pages.sqlite` — page metadata + `sections_json`
  - `glossary.sqlite` — terms, slugs, definitions, categories
  - `schedule.sqlite` — recurring classes, times, formats
  - `testimonials.sqlite` — quotes, authors, featured flag
  - `blog.sqlite` — posts, slugs, body HTML, tags
- **Why standalone databases** — a single SQLite file per domain is easier to query in isolation, faster to back up or swap, and avoids table-lock contention between unrelated content types. Never combine unrelated content domains into one DB to save files. The cost of an extra `.sqlite` is zero.
- **URL routing** — nginx maps URL patterns to `QUERY_STRING type=X&slug=Y` passed to `router.php`. Adding a page = insert a row in `pages.sqlite`. No nginx edit needed for standard slug patterns.
- **No static HTML page files** — no `about/index.html`, no `services/index.html`. All page content lives in SQLite. To find or update copy: `sqlite3 src/api/data/pages.sqlite`.
- **Vanilla JavaScript** — no frameworks. `fetch`, `IntersectionObserver`, `querySelector`.
- **Plain CSS** — `tokens.css` (design tokens) + `main.css` (components). No Sass, no Tailwind.
- **Docker** — `php:8.3-fpm-alpine` + nginx + supervisord in a single container. SQLite DBs baked into image at build time.
- **Resend** — transactional email via `src/api/contact.php` (PHP curl, not Python).
- **clear_env = no** — CRITICAL: `infra/php-fpm-pool.conf` must contain `clear_env = no` or `getenv()` returns empty string in PHP. Never remove this line.
- **Dev view** — `http://localhost:{port}/_devview` — split view: PHP router output (left), DB query trace (top right), live iframe (bottom right). Volume-mounted, no rebuild needed.
- **References:** `vibrantyou.yoga` (migrated 2026-05-22), `arisingmedia.us`

## Directory structure

```
{domain}/
├── src/
│   └── api/
│       ├── router.php              ← URL dispatcher (never edit for standard slugs)
│       ├── contact.php             ← form handler (Resend, PHP curl only)
│       ├── templates/              ← one .php per page type (thin shells, no copy)
│       ├── components/
│       │   ├── _header.php         ← DOCTYPE → <main> (nav, tokens, CSS links)
│       │   └── _footer.php         ← </main> → </html> (footer HTML + vanilla JS)
│       └── data/                   ← all SQLite files (seeded by build/seed_databases.py)
│           ├── pages.sqlite
│           ├── blog.sqlite
│           └── *.sqlite            ← one file per content domain
├── assets/
│   ├── css/
│   │   ├── tokens.css              ← design tokens (colors, spacing, type scale)
│   │   └── main.css                ← all components, layout, responsive breakpoints
│   ├── js/
│   │   └── main.js                 ← vanilla JS only — fetch, IntersectionObserver, querySelector
│   └── images/                     ← .webp only
├── build/
│   └── seed_databases.py           ← creates + seeds all SQLite DBs from source data
├── infra/
│   ├── nginx.conf                  ← URL routing rules → QUERY_STRING → router.php
│   ├── supervisord.conf            ← manages nginx + php-fpm in one container
│   └── php-fpm-pool.conf           ← MUST contain clear_env = no
├── .planning/                      ← never served, never in Docker image
│   ├── README.md                   ← project notes, content sources, decisions
│   ├── wireframe.html              ← design reference
│   ├── DNS_*.txt                   ← DNS records, Cloudflare config
│   └── review_*.png                ← design review screenshots
├── Dockerfile                      ← php:8.3-fpm-alpine, SQLite DBs baked in at build
├── docker-compose.yml              ← port mapping, env vars, volume mounts for dev
├── .cpanel.yml                     ← cPanel deployment (used when host is shared/cPanel)
├── .htpasswd                       ← basic auth for staging (never committed with real credentials)
├── .dockerignore
├── .gitignore                      ← .env, .htpasswd, *.sqlite (if seeded at runtime)
├── 404.html
├── 500.html
├── robots.txt
└── sitemap.xml
```

## How a URL resolves

```
GET /glossary/asana/
  → nginx matches location ~ ^/glossary/([a-z0-9-]+)/$
  → sets QUERY_STRING type=glossary_term&slug=asana
  → fastcgi_pass to router.php
  → router.php: type=glossary_term → require templates/glossary.php
  → glossary.php: SELECT * FROM glossary.sqlite WHERE slug='asana'
  → require _header.php → render term → require _footer.php
  → HTML response
```

## Adding a new page

1. Insert a row in `pages.sqlite`: slug, title, hero fields, sections_json
2. Re-seed: `python3 build/seed_databases.py`
3. Rebuild Docker: `docker compose build && docker compose up -d`
4. Standard slug (single word, no dynamic param): no nginx edit needed
5. New URL pattern: add location block to `infra/nginx.conf`

## Components — mandatory pattern

Every page is assembled from three layers:

1. `_header.php` — outputs `<!DOCTYPE html>` through `<main>`. Contains: nav links (from DB), CSS token/main links, meta tags. Never hardcode nav items in HTML; always query `nav.sqlite` or `pages.sqlite`.
2. Template file (`templates/{type}.php`) — queries its SQLite DB, loops `sections_json`, renders each section type via a `switch`. This is the only place rendering logic lives.
3. `_footer.php` — closes `</main>` through `</html>`. Contains: footer HTML, vanilla JS `<script>` tags, Resend form init if needed.

No other rendering pattern is used. Content flows: database → template → component frame → browser. Never the other way.

## Deployment targets

Two valid deployment targets. Both are supported per project:

- **Docker** (`Dockerfile` + `docker-compose.yml`) — standard for all VPS/VM hosting. Single container: `php:8.3-fpm-alpine` + nginx + supervisord. SQLite DBs baked into image at build time. Run: `docker compose build && docker compose up -d`.
- **cPanel** (`.cpanel.yml`) — used when client host is shared/cPanel (e.g., Namecheap, GoDaddy). `.cpanel.yml` defines deploy path. PHP and SQLite available on host. No Docker needed.

Both targets use the same source. `.cpanel.yml` is always present in the project root even if Docker is the primary target, so the project is portable.

## Staging password protection

Staging environments use HTTP basic auth via `.htpasswd` + nginx `auth_basic` block. Never deploy a staging URL without password protection. The `.htpasswd` file is gitignored. Credentials are stored in 1Password under the project name. When a site goes live, remove the `auth_basic` block from `nginx.conf` and rebuild.

## .planning/ — build notes and planning artifacts

Everything that is not served to the browser lives in `.planning/`. This includes:

- `README.md` — project notes, content sources, client decisions, port number
- Wireframes, design references, screenshots
- DNS records and Cloudflare config notes
- Seed data CSV/JSON before it is loaded into SQLite
- Build scripts no longer needed at runtime

`.planning/` is excluded from the Docker image via `.dockerignore` and from cPanel deploys via `.cpanel.yml` exclude rules. It is committed to git so the build history is preserved.

## Editing page content

- All body copy is in `sections_json` column of `pages.sqlite`
- Edit `build/seed_databases.py` → `seed_sections()` → re-run → rebuild
- Never put body copy in PHP template files

## What we never use

- Node.js / npm packages on the website. Front-end JS uses zero packages
- WordPress for new builds (we migrate clients out of WordPress)
- CSS frameworks (Bootstrap, Tailwind, Bulma)
- JS frameworks (React, Vue, Angular, Svelte)
- jQuery, Lodash, Moment, axios, or any utility library
- CSS-in-JS, styled-components
- Build tools that require `node_modules` (webpack, vite, parcel, esbuild)
- Tracking pixels other than what the client explicitly requests
- A single monolithic database for unrelated content domains
- Hardcoded copy in PHP templates

## Why this stack

1. **Maintainability** — content is in the database. Changing copy never touches code. Templates and data stay separate.
2. **Performance** — PHP + SQLite on Alpine with no framework overhead serves in <50ms. No parse cost from JS bundles.
3. **Longevity** — no dependency rot. A site built today still runs in 10 years with no maintenance.
4. **Security** — no npm audit warnings, no supply-chain attack vectors, no transitive deps to patch.
5. **Auditability** — every line on the site is something we wrote and can read in plain text.
6. **Hosting** — a single Docker container fits the smallest VM tier any provider sells.


number: 01
filename: 01-project-structure.md
title: 01 — Project Structure
updated_at: 2026-05-24T14:17:08.821481
full_content:
# 01 — Project Structure

Two folders per project: source and deployment.

## Source folder

Lives in the dev tree under `concept-agent/projects/{domain}/site/`.
Contains everything needed to maintain and rebuild the site.

```
{domain}/site/
├── index.html                    # home page
├── about/index.html              # /about/
├── contact/index.html            # /contact/
├── reviews/index.html            # /reviews/
├── blog/index.html               # /blog/
├── locations/                    # location pages
│   ├── index.html                # /locations/
│   ├── _template.html            # template stamped with JSON
│   ├── buffalo.html              # generated, flat URL
│   ├── amherst.html
│   └── ...
├── services/
│   ├── index.html
│   ├── _template.html
│   ├── floor-refinishing.html
│   └── ...
├── components/
│   ├── header.html               # loaded via fetch() by components.js
│   └── footer.html
├── data/
│   ├── locations.json            # source data for build_locations.py
│   └── services.json             # source data for build_services.py
├── assets/
│   ├── css/
│   │   ├── main.css              # variables, reset, layout
│   │   └── components.css        # cards, hero, header, footer, nav, responsive
│   ├── js/
│   │   ├── main.js               # scroll animations, count-up, etc.
│   │   ├── components.js         # fetch + inject header/footer
│   │   └── form.js               # form validation + submit
│   ├── images/
│   ├── videos/                   # hero video files (.mp4 + .webm)
│   └── fonts/                    # only if not using Google Fonts CDN
├── build_locations.py            # JSON → flat .html stamping
├── build_services.py
└── README.md                     # project notes, content sources, status
```

## Deployment folder

Lives at `/home/sirdrez/arisingmedia-websites/{domain}/`.
Contains ONLY what's needed to run `docker compose up`.

```
{domain}/
├── index.html                    # all public website folders
├── about/                        # ↑
├── assets/                       # ↑
├── blog/                         # ↑
├── components/                   # ↑
├── contact/                      # ↑
├── locations/                    # ↑
├── reviews/                      # ↑
├── services/                     # ↑
├── api/                          # form-submit Python service (if used)
│   ├── server.py
│   ├── Dockerfile
│   ├── .env                      # gitignored — Resend key, etc.
│   └── .env.example
├── Dockerfile                    # nginx web container
├── nginx.conf
├── docker-compose.yml
├── .dockerignore
├── .gitignore
└── .planning/                    # everything not needed at runtime
    ├── build_locations.py        # build scripts moved here
    ├── data/                     # JSON sources moved here
    ├── README.md
    ├── DNS_*.txt                 # DNS notes
    └── review_*.png              # design review screenshots
```

## What goes where

**Source folder gets** every working file (build scripts, data JSON, screenshots,
notes, raw assets). This is the dev/maintenance copy. NOT what gets deployed.

**Deployment folder gets** ONLY the rendered website + the small API service.
Build scripts, JSON data, and notes go into `.planning/` to keep root clean and
prevent accidental web exposure.

## Sync flow

```
source/site/  →  rsync (excludes .planning, build_*.py, data/, etc.)  →  deployment/
                                                                        ↓
                                                              docker compose up -d --build
```

Sync command in `04-content-rules.md` and `08-deployment-docker.md`.

## URL structure — two valid patterns

### Pattern A: Flat HTML (default for Docker/nginx projects)

nginx `try_files $uri $uri/ $uri.html =404` serves `/locations/buffalo` and
`/locations/buffalo.html`. Canonical form: `/locations/buffalo.html`.

Why flat:
- One file = one page, no `/index.html` confusion
- Easier sitemap generation
- `<a href>` links are unambiguous
- Crawl budget benefit — Google indexes one URL per page, not two

### Pattern B: Directory-style (default for cPanel/Apache projects)

Each page lives at `{slug}/index.html`. Apache auto-serves `index.html` when
visiting `/{slug}/`. Use this when deploying to cPanel shared hosting.

```
services/
├── carpet-cleaning/index.html     → /services/carpet-cleaning/
├── stairs/index.html              → /services/stairs/
commercial/
├── offices/index.html             → /commercial/offices/
└── vacation-rentals/index.html    → /commercial/vacation-rentals/
```

### Lahrcarpetcleaning.com folder structure (directory-style, cPanel)

```
lahrcarpetcleaning.com/
├── index.html
├── about/index.html
├── contact/index.html
├── reviews/index.html
├── service-area/index.html
├── locations/
│   ├── index.html
│   ├── waterloo-ny/index.html
│   ├── geneva-ny/index.html
│   └── ... (20 location pages)
├── services/
│   ├── carpet-cleaning/index.html
│   ├── stairs/index.html
│   ├── upholstery/index.html
│   ├── floors/index.html
│   ├── area-rugs/index.html
│   ├── add-ons/index.html
│   └── commercial/index.html
├── commercial/
│   ├── offices/index.html
│   ├── vacation-rentals/index.html
│   ├── hotels-inns/index.html
│   ├── retail-showrooms/index.html
│   └── property-management/index.html
├── assets/
│   ├── css/styles.css?v=N          ← always cache-bust on change
│   ├── js/
│   │   ├── main.js
│   │   └── components.js           ← injects nav+footer via innerHTML
│   ├── images/
│   │   ├── hero/                   ← hero-{slug}.webp, one per page
│   │   └── services/               ← {service}.webp card images
│   └── videos/hero/hero-reel.mp4
├── tools/                          ← NOT deployed to webroot
│   ├── convert-to-webp.py
│   ├── gen-images-flux.py
│   └── gen-hero-images.py
├── .cpanel.yml
├── robots.txt
├── sitemap.xml
├── 404.html
└── 500.html
```

All images are `.webp` — see `12-image-assets.md`.
cPanel deployment — see `08b-deployment-cpanel.md`.


number: 02
filename: 02-wordpress-to-html-migration.md
title: 02 — WordPress to Static HTML Migration (Stack B only)
updated_at: 2026-05-24T14:17:08.821559
full_content:
# 02 — WordPress to Static HTML Migration (Stack B only)

> **Stack B only — sites with fewer than 50 pages hosted on cPanel shared hosting.**
> For Stack A migrations (50+ pages, PHP router + SQLite, Docker), use the
> `wp-divi-pipeline-to-am-stack/` folder and its CLI launcher instead:
> `python3 scripts/migrate.py --wpress /path/to/backup.wpress --domain example.com`

The playbook for migrating a WordPress (Divi, Elementor, classic, whatever) site
to vanilla static HTML.

## Phase 1 — Capture source

Before touching anything, capture the current site so nothing is lost.

1. **Database dump** — `wp db export ${domain}.sql --add-drop-table`
2. **Wp-content snapshot** — tar the entire `wp-content/` (themes, plugins, uploads)
3. **Crawl the live site** — use `wget --mirror --convert-links --adjust-extension --page-requisites --no-parent https://{domain}` to capture rendered HTML + all assets
4. **Inventory pages** — list every URL returning 200 (use the sitemap if it has one)
5. **Inventory forms** — note every Gravity Form / Contact Form 7 / etc. field-by-field
6. **Inventory dynamic features** — search, comments, members, anything truly dynamic

Save all of this in the project's `.planning/` folder.

## Phase 2 — Decide what to keep

Re-design pass. Most WP sites have:
- Bloated copy → cut by 30-50%
- Outdated/inflated metrics → remove or replace with real, verifiable data
- Stock photos → replace with real client photos when available
- Cluttered layouts → strip back to one clear CTA per section
- Plugin features the client never uses → drop entirely

Show the client a wireframe of the simplified structure before building anything.

## Phase 3 — Information architecture

Standard structure for a small business:

```
/                            home
/about/                      about / story / team
/services/                   services index
/services/{slug}.html        one detail page per service
/locations/                  locations index
/locations/{city}.html       one detail page per service area (SEO gold)
/reviews/                    customer reviews
/contact/                    contact + form
/blog/                       optional blog index
```

For each location and each service: one flat `.html` page generated from JSON +
template. See `03-build-pipeline.md`.

## Phase 4 — Build

1. Set up source folder per `01-project-structure.md`
2. Write `assets/css/main.css` (variables, reset, typography, layout)
3. Write `assets/css/components.css` (header, footer, hero, cards, forms)
4. Write `components/header.html` and `components/footer.html`
5. Write `assets/js/components.js` (fetch + inject header/footer)
6. Write `assets/js/main.js` (scroll animations, anything page-wide)
7. Build `index.html` first — this is the design system in working form
8. Generate location and service detail pages from JSON (see `03`)
9. Build remaining pages: about, contact, reviews, blog index

## Phase 5 — Forms

If the WP site had Gravity Forms or similar, build a vanilla replacement:
- HTML form in `contact/index.html` (and inline on service/location pages if needed)
- Client-side validation in `assets/js/form.js`
- POST to `/api/estimate` (or similar) handled by Python stdlib service
- Server-side validation, reCAPTCHA verification, send via Resend

See `07-form-handling.md`.

## Phase 6 — SEO parity

Before launch, every old URL must either:
- Have a matching new URL with the same or better content, OR
- 301-redirect to a relevant new URL

Build a redirect map from the old WP sitemap. Add to `nginx.conf`:

```nginx
location = /old-page-slug { return 301 /new-slug.html; }
location = /?p=123 { return 301 /about/; }
```

Per-page parity checklist:
- `<title>` matches or improves on the WP title
- `<meta name="description">` matches or improves
- `<link rel="canonical">` is set to the new URL
- Headings (h1, h2, h3) preserve the topical structure
- Internal links updated to new URLs
- Image alt text preserved or improved
- Schema.org JSON-LD added (`LocalBusiness`, `Service`, `BreadcrumbList`)

## Phase 7 — Switch DNS / cutover

1. Deploy the static site to a separate URL first (`new.{domain}`) for client review
2. Once approved, point production DNS to the new container
3. Keep the WP container running for 14 days as fallback
4. Submit new sitemap to Google Search Console
5. Use Search Console URL inspection on 5-10 key pages to confirm indexing

## Phase 8 — Post-launch

- Monitor Search Console for crawl errors / 404s, fix in nginx as redirects
- Monitor form submissions — first real lead through the new form is the
  ultimate "it works" check
- Decommission WP only after 30 days of clean operation

## What NOT to do

- Do not run a "headless WordPress" or "WordPress as API" — that defeats the
  whole point. Static means static.
- Do not use a static-site-generator tool (Hugo, 11ty, Jekyll, Astro, Next.js
  static export). We hand-write HTML and use small Python build scripts only
  where data is repeated.
- Do not migrate the database. Content gets re-written cleaner during migration.


number: 03
filename: 03-build-pipeline.md
title: 03 — Build Pipeline
updated_at: 2026-05-24T14:17:08.821618
full_content:
# 03 — Build Pipeline

When a site has many similar pages (location pages, service pages, blog posts,
team-member pages), do not hand-write each one. Use a JSON + template + Python
build script.

## When to use a build script

Use it when there are 4+ pages with identical structure differing only in
content. For example: 6 location pages where only the city name and
city-specific copy differs.

For one-off pages (home, about, contact, services index), hand-write the HTML
directly. Build scripts are for repetition, not for everything.

## Pattern

Three files per template family:

1. **`data/{thing}.json`** — array of objects, one per page
2. **`{thing}/_template.html`** — HTML with `{{placeholder}}` markers
3. **`build_{thing}.py`** — stdlib Python, stamps template with data

### Example: locations.json

```json
[
  {
    "slug": "buffalo",
    "city": "Buffalo",
    "state": "NY",
    "title": "Hardwood Floor Refinishing in Buffalo, NY | Floor It",
    "meta_description": "Professional hardwood floor refinishing...",
    "canonical": "https://floorithardwoodfloors.com/locations/buffalo.html",
    "hero_h1": "Hardwood Floor Refinishing in Buffalo, NY",
    "hero_lead": "Western New York's most experienced...",
    "overview_h2": "Buffalo's Trusted Floor Refinishing Specialists",
    "overview_body_1": "...",
    "overview_body_2": "...",
    "faqs": [
      { "q": "...", "a": "..." }
    ]
  }
]
```

### Example: _template.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <title>{{title}}</title>
  <meta name="description" content="{{meta_description}}">
  <link rel="canonical" href="{{canonical}}">
  ...
</head>
<body>
  <h1>{{hero_h1}}</h1>
  <p>{{hero_lead}}</p>
  ...
</body>
</html>
```

### Example: build_locations.py (skeleton)

```python
"""Build flat .html location pages from data/locations.json + locations/_template.html."""
import json, sys
from pathlib import Path

SITE_ROOT     = Path(__file__).parent
DATA_FILE     = SITE_ROOT / "data" / "locations.json"
TEMPLATE_FILE = SITE_ROOT / "locations" / "_template.html"
OUT_DIR       = SITE_ROOT / "locations"

def render(template: str, item: dict) -> str:
    out = template
    for key, value in item.items():
        if isinstance(value, (str, int, float)):
            out = out.replace("{{" + key + "}}", str(value))
    # Custom rendering for nested arrays (e.g. faqs)
    # ... handle item['faqs'] etc.
    return out

def main():
    data = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    template = TEMPLATE_FILE.read_text(encoding="utf-8")
    print(f"Building {len(data)} location pages...")
    for item in data:
        rendered = render(template, item)
        outfile = OUT_DIR / f"{item['slug']}.html"
        outfile.write_text(rendered, encoding="utf-8")
        print(f"  Built: {outfile.relative_to(SITE_ROOT)}")
    print(f"Done. {len(data)} pages written.")

if __name__ == "__main__":
    main()
```

## Rules

1. **Source of truth is JSON, not HTML.** When content needs to change, edit the
   JSON and re-run the build script. Never hand-edit a generated `.html` file —
   the next build will overwrite your changes.

2. **Generated files land in the same folder as their template.** Do not nest
   into a subfolder. The template file is always named `_template.html` (leading
   underscore so it sorts above the generated pages).

3. **Build script lives in the SOURCE root**, not in deployment. After running
   the build, sync the rendered `.html` files (not the script, not the JSON) to
   deployment.

4. **Verify zero unreplaced placeholders** after every build:
   ```bash
   grep -rn "{{" {thing}/*.html  # should return nothing
   ```

5. **Build is idempotent.** Running it twice produces identical files.

## Stamping rules — escaping

When a JSON value gets stamped into an HTML attribute or `<title>`, special
characters can break the page. Use these rules:

- Plain text in `<p>` or `<h1>`: ampersand-encode (`&` → `&amp;`)
- `<title>` content: ampersand-encode + strip line breaks
- `<meta>` content attribute: encode `&`, `"`, and remove line breaks
- `href` URL attribute: never put user input here, but if needed, urlencode

For our typical use case (controlled content authored by us), the simple
`str.replace("{{key}}", value)` is sufficient because we don't have hostile
input. Just don't put angle brackets or quotes in the JSON values.

## Re-running the build

```bash
cd {project}/site
python3 build_locations.py
python3 build_services.py
```

After build, sync the rendered files to deployment (see `08-deployment-docker.md`).


number: 04
filename: 04-content-rules.md
title: 04 — Content Rules
updated_at: 2026-05-24T14:17:08.821671
full_content:
# 04 — Content Rules

Standards for every word that goes on an Arising Media website.

## No em-dashes or en-dashes — anywhere

Do not use `—` (em-dash) or `–` (en-dash) on any page. They look like AI-generated
copy and trip spam filters in transactional email. Replace with:
- A period: `solution — fast` → `solution. Fast`
- A comma: `Buffalo — including older homes` → `Buffalo, including older homes`
- A colon: `services available: refinishing, restoration` (instead of `— refinishing`)
- The word "to": `Mon-Sat: 8 AM-5 PM` → `Monday to Saturday: 8 AM to 5 PM`
- The word "and": `clear timelines — professional workmanship` → `clear timelines and professional workmanship`

This applies to:
- Page body copy
- `<title>` tags (use `|` pipe instead)
- `<meta name="description">`
- Image `alt` text
- Form option labels (`Good — Minor scratches` → `Good: Minor scratches`)
- Email subject lines and body
- Schema.org JSON-LD content

Run a final check before declaring done:
```bash
grep -rn '—\|–\|&mdash;\|&ndash;' site/ --include="*.html" --include="*.json"
```
Result must be zero matches.

## No fake numbers, ratings, or claims

Never invent metrics. The site can only state numbers that the client can prove.

Forbidden without proof:
- "100+ stain colors" (do they actually have 100? if not, say "multiple stain options")
- "HEPA dustless sanding" (does the equipment actually have HEPA filtration? if not, say "dustless sanding")
- "99% satisfaction rate" (where does this number come from?)
- "Award-winning" (which award?)
- "#1 in [city]" (according to whom?)
- Specific years of experience without confirming

If the client has Google reviews showing 4.9 stars, that's a real number, use it.
If the client has 30 years in business, use it. If neither is documented, omit.

## No filler / AI-tells

Watch for and remove:
- "In today's fast-paced world..."
- "Look no further!"
- "Whether you're X or Y..."
- "Don't hesitate to contact us"
- "We pride ourselves on..."
- "State-of-the-art technology"
- "Cutting-edge solutions"
- Every sentence ending in an exclamation point

These are LLM defaults. Real businesses don't write like this.

## Tone

Match what the client's actual customers would say. For a hardwood-floor
business in Buffalo:
- Write at 7th-8th grade reading level
- Lead with the customer's problem (worn floors, water damage)
- One clear CTA per section (call, request estimate, view services)
- Active voice ("We refinish floors") not passive ("Floors are refinished")
- Short paragraphs (2-4 sentences max)
- No marketing jargon ("synergize", "leverage", "best-in-class")

## Specificity over superlative

- "We restored a 90-year-old oak floor in Elmwood Village last month" is better than "We're experts at historic floor restoration"
- "Most projects take 7 to 10 business days" is better than "We work fast"
- "The technician took moisture readings before sanding" is better than "Professional preparation"

## Hours / phone / address formatting

- Phone: `(716) 602-1429` or `tel:+17166021429`
- Hours: `Monday to Saturday: 8:00 AM to 5:00 PM` (no dashes)
- Address: full mailing address with state and zip
- All three appear in the footer on every page, in identical format
- All three appear in `LocalBusiness` schema JSON-LD

## Headings

- One `<h1>` per page (the page's main topic)
- `<h2>` for major sections
- `<h3>` for cards/items within a section
- Never skip levels (no `<h1>` then `<h3>`)
- Headings describe the section content, not "Welcome" or "Section 2"

## Links

- Internal links: relative path with `.html` (`/services/floor-refinishing.html`)
- External links: include `target="_blank" rel="noopener"`
- Phone links: `tel:+1{10digits}` (no spaces)
- Email links: `mailto:address`
- Map links: open in new tab

## Images

- Every `<img>` needs descriptive `alt` text or `alt=""` if purely decorative
- Use `loading="lazy"` on every image except the hero
- Use `.webp` for photos, `.svg` for icons/logos
- Provide `width` and `height` attributes to prevent layout shift


number: 05
filename: 05-mobile-responsive.md
title: 05 — Mobile Responsive
updated_at: 2026-05-24T14:17:08.821721
full_content:
# 05 — Mobile Responsive

## Breakpoints

Standard set, used in every project:

```css
/* Default: mobile-first (no media query) — styles for 320px+ */

@media (max-width: 360px) { /* iPhone SE portrait — tightenings */ }
@media (max-width: 480px) { /* small phones */ }
@media (max-width: 600px) { /* phones (gap reductions, font tightening) */ }
@media (max-width: 768px) { /* tablets — main mobile breakpoint */ }
@media (max-width: 900px) { /* small laptops, tablet landscape */ }
@media (max-width: 1023px) { /* IMPORTANT: switch to mobile menu here */ }
@media (max-width: 1024px) { /* sub-desktop — tighten spacing */ }
```

## Header / nav rule

Switch to the slide-out mobile menu at `max-width: 1023px`, NOT 768px.

Reason: a typical header has logo + 5-7 nav items + phone number + CTA button.
That doesn't fit cleanly until the viewport is at least 1024px wide. Below
that, the desktop nav cramps and the CTA button overflows off the right edge.

```css
@media (max-width: 1023px) {
  .header-nav { display: none; }
  .header-phone { display: none; }
  .header-menu-btn { display: flex; }
}
```

## Inline-style grid override

Section layouts often use inline `style="display:grid;grid-template-columns:1fr 1fr"`
for image+text splits. Inline styles override CSS — but `!important` inside a
media query DOES override inline styles.

Always include this block at the END of `components.css`:

```css
@media (max-width: 900px) {
  [style*="grid-template-columns:1fr 1fr"],
  [style*="grid-template-columns: 1fr 1fr"],
  [style*="grid-template-columns:repeat(2,1fr)"],
  [style*="grid-template-columns: repeat(2,1fr)"],
  [style*="grid-template-columns:repeat(2, 1fr)"],
  [style*="grid-template-columns: repeat(2, 1fr)"] {
    grid-template-columns: 1fr !important;
    gap: 2rem !important;
  }

  /* Order overrides — flip image/content columns reset on mobile */
  [style*="order:1"], [style*="order: 1"],
  [style*="order:2"], [style*="order: 2"] {
    order: 0 !important;
  }
}
```

## Page-overflow protection

Mobile slide-out nav panels and other off-screen positioned elements can
expand `document.documentElement.scrollWidth` past the viewport, creating an
unwanted horizontal scroll. Always include:

```css
html, body {
  overflow-x: clip;
  max-width: 100%;
}
```

## Form field overflow

`<input>`, `<select>`, and `<textarea>` have intrinsic widths that propagate up
through flex/grid containers via `min-width: auto` defaults. On narrow screens
this pushes the layout wider than the viewport.

```css
@media (max-width: 900px) {
  .contact-layout,
  .contact-form-wrap,
  .contact-info-list,
  .form-grid,
  .form-grid--2,
  .form-field { min-width: 0; }
  .form-field input,
  .form-field textarea,
  .form-field select {
    min-width: 0;
    max-width: 100%;
    box-sizing: border-box;
  }
}
```

## 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


number: 06
filename: 06-seo-meta.md
title: 06 — SEO, Meta, and Schema
updated_at: 2026-05-24T14:17:08.821786
full_content:
# 06 — 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, `—` only in title brand block
- 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.

---

## JSON-LD Schema — By Site Type

Schema goes in `<script type="application/ld+json">` in every page `<head>`. Choose the type that matches the site.

### LocalBusiness (service businesses — carpet cleaning, flooring, consulting, healthcare)

```json
{
  "@context": "https://schema.org",
  "@type": "LocalBusiness",
  "name": "{Business Name}",
  "url": "https://{domain}",
  "telephone": "{phone}",
  "email": "{email}",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "{street}",
    "addressLocality": "{city}",
    "addressRegion": "{state}",
    "postalCode": "{zip}",
    "addressCountry": "US"
  },
  "description": "{Business description}",
  "priceRange": "$$",
  "areaServed": "{City, State}",
  "openingHours": "Mo-Fr 09:00-17:00"
}
```

### WebApplication (SaaS tools, online converters, web apps)

```json
{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "name": "{App Name}",
  "url": "https://{domain}",
  "description": "{What the tool does}",
  "applicationCategory": "UtilitiesApplication",
  "operatingSystem": "Any",
  "offers": {
    "@type": "Offer",
    "price": "0",
    "priceCurrency": "USD"
  },
  "provider": {
    "@type": "Organization",
    "name": "Arising Media",
    "url": "https://arisingmedia.us"
  }
}
```

### ProfessionalService (consultants, agencies, coaching)

```json
{
  "@context": "https://schema.org",
  "@type": "ProfessionalService",
  "name": "{Name}",
  "url": "https://{domain}",
  "description": "{Description}",
  "serviceType": "{Service category}",
  "provider": {
    "@type": "Person",
    "name": "{Person name}"
  }
}
```

### FAQPage (add to FAQ pages only, in addition to the base schema)

```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "{Question text?}",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "{Answer text}"
      }
    }
  ]
}
```


number: 07
filename: 07-form-handling.md
title: 07 — Form Handling
updated_at: 2026-05-24T14:17:08.821902
full_content:
# 07 — Form Handling

Static sites can't send email by themselves. Every project that needs a
contact form gets a small Python service running in its own Docker container,
proxied by nginx.

## Architecture

```
Browser  →  POST /api/estimate          (vanilla JS fetch in form.js)
            ↓
nginx    →  proxies /api/ to api:3001   (strips /api/ prefix)
            ↓
Python service (server.py, stdlib only)
  - Validates fields server-side
  - Verifies reCAPTCHA v3 with Google
  - Sends via Resend HTTPS API
  - Returns {ok: true} or {error: ...}
```

## Front-end (vanilla JS)

`assets/js/form.js`:

- Real-time validation (blur events)
- Phone formatting `(###) ###-####`
- Email regex check
- Required-field check
- Async submit to `/api/estimate` with JSON body
- Disable submit button + show "Sending..." during request
- Show success/error message in `.form-status` span
- Reset form on success
- reCAPTCHA v3 token fetched before submit and included in body

## Back-end (Python stdlib)

`api/server.py`:

```python
#!/usr/bin/env python3
import hashlib, http.server, json, os, re, socketserver, time
import urllib.parse, urllib.request

PORT             = int(os.environ.get("PORT", "3001"))
RESEND_API_KEY   = os.environ.get("RESEND_API_KEY", "")
RECAPTCHA_SECRET = os.environ.get("RECAPTCHA_SECRET", "")
TO_EMAIL         = os.environ.get("TO_EMAIL", "")
FROM_EMAIL       = os.environ.get("FROM_EMAIL", "")
RECAPTCHA_MIN    = float(os.environ.get("RECAPTCHA_MIN", "0.5"))

PHONE_RE = re.compile(r"^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$")
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")

# Rate limit: 5 requests / IP / 15 minutes
RATE_MAP = {}
RATE_WINDOW = 15 * 60
RATE_MAX = 5

def sanitize(s):
    if not isinstance(s, str): return ""
    return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;").strip()[:2000]

def validate_fields(body):
    errors = []
    if not body.get("name") or len((body["name"]).strip()) < 2: errors.append("name")
    if not EMAIL_RE.match((body.get("email") or "").strip()): errors.append("email")
    if not PHONE_RE.match((body.get("phone") or "").replace(" ", "")): errors.append("phone")
    # ... etc
    return errors

def verify_recaptcha(token):
    if not RECAPTCHA_SECRET or not token: return 0.0
    data = urllib.parse.urlencode({"secret": RECAPTCHA_SECRET, "response": token}).encode()
    req = urllib.request.Request("https://www.google.com/recaptcha/api/siteverify", data=data)
    try:
        with urllib.request.urlopen(req, timeout=8) as resp:
            return float(json.loads(resp.read()).get("score", 0))
    except Exception:
        return 0.0

def send_via_resend(fields):
    safe = {k: sanitize(fields.get(k,"")) for k in ["name","email","phone","address","city","zip","service","condition","message"]}
    html = f"""<!DOCTYPE html>...{safe['name']}..."""
    text = f"New estimate request\n\nName: {safe['name']}\n..."
    payload = json.dumps({
        "from": FROM_EMAIL,
        "to": [TO_EMAIL],
        "reply_to": fields.get("email","").strip(),
        "subject": f"New estimate request: {safe['name']} ({safe['city']})",
        "html": html, "text": text,
    }).encode("utf-8")
    idem = hashlib.sha256(payload).hexdigest()[:64]
    req = urllib.request.Request("https://api.resend.com/emails", data=payload, headers={
        "Authorization": f"Bearer {RESEND_API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
        "User-Agent": "{Brand}-Estimate-Form/1.0",  # IMPORTANT: Cloudflare blocks default Python UA
    })
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            if resp.status >= 300: raise RuntimeError(f"Resend {resp.status}: {resp.read().decode('utf-8','ignore')}")
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"Resend {e.code}: {e.read().decode('utf-8','ignore')}") from None
```

Full reference implementation: see `floorithardwoodfloors.com/api/server.py`.

## Critical: User-Agent header

When calling the Resend API from Python, you MUST set a non-default User-Agent.
Cloudflare (which fronts Resend) blocks Python's default `Python-urllib/3.x`
with HTTP 403 / Cloudflare error code 1010.

```python
"User-Agent": "{ProjectName}-Form/1.0"
```

## Idempotency

Every Resend request includes an `Idempotency-Key` header set to the SHA-256
of the payload (truncated to 64 chars). Identical payloads within 24 hours
are deduplicated by Resend automatically. This prevents:
- Double-clicks creating two leads
- Browser retries after a network blip
- Honest user submitting twice

## Security checklist

- API key in `.env` file, NOT in source control. `.gitignore` it.
- API key NEVER reaches the browser bundle (only the server has it)
- `.env` file lives in `api/`, NOT in the nginx web root
- Server-side validation on EVERY field — never trust client
- HTML-escape every field rendered into the email body to prevent injection
- Rate limit per IP (5 / 15 min default)
- 16 KB body cap — reject anything larger
- 10-second upstream timeout — don't hold connections open
- CORS locked to the production domain only (`Access-Control-Allow-Origin: https://{domain}`)
- reCAPTCHA v3 with score threshold (default 0.5) once secret is configured

## Environment variables

`api/.env`:
```
RESEND_API_KEY=re_xxxxxxxxxxxx
RECAPTCHA_SECRET=6Ldq...
TO_EMAIL=leads@{domain}
FROM_EMAIL=Brand Name <webleads@{domain}>
RECAPTCHA_MIN=0.5
PORT=3001
```

`api/.env.example` (committed) is the same file with placeholder values.

## reCAPTCHA setup

1. Create site at https://www.google.com/recaptcha/admin
2. Type: **reCAPTCHA v3** (not v2)
3. Add your domain
4. Copy the **site key** into `assets/js/form.js`:
   ```js
   const RECAPTCHA_SITE_KEY = '6Ldq...';
   ```
5. Add the script tag to pages with the form:
   ```html
   <script src="https://www.google.com/recaptcha/api.js?render=6Ldq..."></script>
   ```
6. Copy the **secret key** into `api/.env` as `RECAPTCHA_SECRET`

## Deliverability checklist

When emails are landing in spam:
1. Verify Resend domain is fully green (SPF + DKIM + DMARC) — see `09-domain-email-dns.md`
2. From name set, not bare email: `Brand Name <webleads@{domain}>`
3. Both `html` and `text` parts in every Resend payload (no HTML-only)
4. Subject line is descriptive, no em-dash, no spam-trigger words
5. Recipient marks first 2-3 emails as "Not Spam" in Gmail to train the filter

## Testing

```bash
# Validation rejection (expect 422)
curl -X POST http://localhost:8096/api/estimate \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"bad"}'

# Full valid submission (expect 200, real email sent)
curl -X POST http://localhost:8096/api/estimate \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"test@example.com","phone":"(716) 555-1234","address":"100 Test St","city":"Buffalo","zip":"14201","service":"refinishing","message":"Test","token":""}'
```

The first real test email confirms end-to-end works.

---

## Alternative Stack: nginx + php-fpm (single container)

Some projects use a single container running nginx + php-fpm (via supervisord)
instead of the two-container Python setup. The form handler is a PHP file
instead of `server.py`. This is the pattern when the project was built with PHP
or the client host expects PHP.

```
Browser  →  POST /api/contact
            ↓
nginx    →  rewrites /api/contact → /api/contact.php (rate-limited)
            ↓
php-fpm  →  /var/www/html/api/contact.php
  - Validates fields server-side
  - Verifies reCAPTCHA v3
  - Sends via Resend HTTPS API (curl or file_get_contents)
  - Returns JSON {ok: true} or {error: ...}
```

### nginx rule for PHP form endpoint

```nginx
location = /api/contact {
    limit_req zone=contact_limit burst=3 nodelay;
    limit_req_status 429;
    rewrite ^ /api/contact.php last;
}

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}
```

### CRITICAL: php-fpm must pass env vars to PHP

By default, php-fpm **strips all environment variables** before spawning worker
processes. PHP scripts then get empty strings from `getenv()`. Fix:

`/etc/php-fpm.d/www.conf` or the pool override file:
```ini
[www]
clear_env = no
```

Without this, `getenv('RESEND_API_KEY')` returns `""` and emails silently
fall back to the default or skip sending with no error in the form response.

### env var naming must match what PHP reads

The `.env` file variable names must exactly match what the PHP script calls
with `getenv()`. Common mismatch pattern:

| `.env` key (wrong)  | What PHP reads        | Effect               |
|---------------------|-----------------------|----------------------|
| `MEGHAN_INBOX_TO`   | `SEND_TO_EMAIL`       | Falls back to default hardcoded value |
| `FROM_ADDRESS`      | `SEND_FROM_EMAIL`     | Falls back to default |

Always read the PHP file first, find every `getenv()` call, and name the
`.env` keys accordingly.

### .env placement for single-container stack

When there is no separate api container, the `.env` lives in the project
root alongside `docker-compose.yml`. The compose file passes it as
`env_file: .env` or explicit `environment:` entries. Confirm with:

```bash
docker exec {container} env | grep RESEND
```


number: 08
filename: 08-deployment-docker.md
title: 08 — Deployment (Universal: Docker + nginx AND cPanel)
updated_at: 2026-05-24T14:17:08.821995
full_content:
# 08 — Deployment (Universal: Docker + nginx AND cPanel)

Every project ships with ALL deployment configs so it can go to either a
Docker VPS or a cPanel shared host without refactoring. The same codebase,
the same HTML files, two deployment paths.

```
project/
├── Dockerfile          ← Docker/VPS path
├── docker-compose.yml  ← Docker/VPS path
├── nginx.conf          ← Docker/VPS path
├── .htaccess           ← cPanel/Apache path
├── .cpanel.yml         ← cPanel Git deploy tasks
└── ... (all HTML/assets)
```

Choose path at deploy time based on the client's host:
- **VPS / DigitalOcean / Linode / custom server** → Docker + nginx
- **Shared hosting / cPanel / WHM** → .cpanel.yml + Apache

Both paths serve identical static files. No code changes needed to switch.

---

## Path A — Docker + nginx (VPS)

Every project deploys as a Docker Compose stack with two containers:
nginx (web) and Python (api). Sites without a form omit the api service.

## docker-compose.yml

```yaml
services:
  web:
    image: {domain}-static
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "{port}:80"
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped

  api:
    image: {domain}-api
    build:
      context: ./api
      dockerfile: Dockerfile
    env_file: ./api/.env
    expose:
      - "3001"
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3001/health',timeout=3).status==200 else 1)"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped
```

Port assignments are unique per project. Track in
`/home/sirdrez/arisingmedia-websites/PORTS.md` so no two projects collide.

## Dockerfile (nginx web container)

CRITICAL — the Dockerfile must explicitly list which folders to copy. Never use
`COPY . /usr/share/nginx/html/` because that copies `.env`, `Dockerfile`,
build scripts, etc. into the web root where they become URL-accessible.

```dockerfile
FROM nginx:alpine

# nginx config — server-only, never served as a static file
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Public website only — explicit list, no wildcards
COPY index.html         /usr/share/nginx/html/
COPY assets             /usr/share/nginx/html/assets/
COPY components         /usr/share/nginx/html/components/
COPY about              /usr/share/nginx/html/about/
COPY blog               /usr/share/nginx/html/blog/
COPY contact            /usr/share/nginx/html/contact/
COPY locations          /usr/share/nginx/html/locations/
COPY reviews            /usr/share/nginx/html/reviews/
COPY services           /usr/share/nginx/html/services/

EXPOSE 80
```

## Dockerfile (api Python container)

```dockerfile
FROM python:3.13-alpine
WORKDIR /app
COPY server.py .
EXPOSE 3001
CMD ["python3", "-u", "server.py"]
```

No pip, no requirements.txt, no node_modules. Python stdlib only.

## nginx.conf

```nginx
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Defense in depth — deny dotfiles, configs, scripts, source files
    location ~ /\. {
        deny all;
        return 404;
    }
    location ~* \.(env|env\.example|conf|yml|yaml|py|pyc|md|txt|sh|sql|log|bak|old|swp|dockerfile)$ {
        deny all;
        return 404;
    }
    location = /Dockerfile {
        deny all;
        return 404;
    }

    # API proxy — strip /api/ prefix, forward to Python service
    location /api/ {
        proxy_pass         http://api:3001/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 10s;
        proxy_connect_timeout 5s;
    }

    # Flat HTML routing — /locations/buffalo serves /locations/buffalo.html
    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Cache static assets aggressively
    location ~* \.(jpg|jpeg|png|webp|svg|ico|css|js|woff2?|mp4|webm)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    gzip on;
    gzip_types text/html text/css application/javascript image/svg+xml;
    gzip_min_length 1024;

    error_page 404 /index.html;
}
```

## .dockerignore

Keeps sensitive files out of the build context (defense layer 1):

```
.git
.gitignore
.dockerignore
api
build_*.py
__pycache__
*.pyc
*.md
*.txt
review_*.png
docker-compose.yml
.DS_Store
.planning
```

Note: `api` is excluded from the WEB build context. The API container has
its own Dockerfile with its own context (`./api`).

## .gitignore

```
api/.env
api/__pycache__/
__pycache__/
*.pyc
*.log
.DS_Store
```

The `api/.env` file is NEVER committed.

## Sync from source to deployment

After every change to source HTML/CSS/JS/assets:

```bash
SITE="/path/to/concept-agent/projects/{domain}/site"
DEPLOY="/home/sirdrez/arisingmedia-websites/{domain}"

rsync -a \
  --exclude=.git --exclude=.planning --exclude=api \
  --exclude=Dockerfile --exclude=nginx.conf --exclude=docker-compose.yml \
  --exclude=.dockerignore --exclude=.gitignore \
  --exclude='build_*.py' --exclude=__pycache__ --exclude=data \
  --exclude='*.md' --exclude='*.txt' --exclude='review_*.png' \
  "$SITE/" "$DEPLOY/"

cd "$DEPLOY"
docker compose up -d --build web
```

The `--exclude` list mirrors `.dockerignore` — keeps deployment-only files
(Dockerfile, nginx.conf) intact and stops dev/source files from polluting
the deployment folder.

## Verify after deploy

Every deploy MUST be audited with `tools/verify-protection.sh` before being
considered live. The script probes a fixed list of sensitive paths
(`Dockerfile`, `.env`, `nginx.conf`, `.planning/`, `__pycache__/`, build
scripts, `.git/`, etc.) and fails if any returns 200.

```bash
~/arisingmedia-websites/am-webdesign-sops/tools/verify-protection.sh \
  http://localhost:{port}
```

Exit codes:
- `0` PASS — every sensitive path 404, every required path reachable.
- `0` PASS (with warnings) — protection clean but `/robots.txt` or
  `/sitemap.xml` missing (content gap, not a leak).
- `1` FAIL — at least one sensitive path returned 200, or `/` is unreachable.

Run it manually after every `docker compose up -d --build`. Wire it into CI
once the site has a remote pipeline. Treat a FAIL as a deploy rollback.

For ad-hoc spot checks beyond the script:

```bash
curl -s -o /dev/null -w "site:    %{http_code}\n" http://localhost:{port}/
curl -s -o /dev/null -w "css:     %{http_code}\n" http://localhost:{port}/assets/css/main.css
curl -s -o /dev/null -w "api:     %{http_code}\n" http://localhost:{port}/api/health
```

All public paths return 200. All sensitive paths return 404.

---

## Project Folder Rename Procedure

WHY: Docker Compose derives its project name from the folder the
`docker-compose.yml` lives in. Renaming the folder changes the compose project
name, which orphans any running containers under the old name. Running
`docker compose up -d` from the new path will fail with:

```
Error: The container name "/vibrantyou-web" is already in use
```

The fix is to explicitly remove the old container before bringing up the new
compose project:

```bash
# Stop and remove the old container by its known name
docker stop {container-name}
docker rm {container-name}

# Now bring up from the renamed folder — clean start
docker compose -f /path/to/renamed-folder/docker-compose.yml up -d
```

Always confirm the env vars loaded correctly after restart:

```bash
docker exec {container-name} env | grep RESEND
```

---

## .env and .gitignore at Project Root

Every project must have both files created at setup, before the first commit:

`.gitignore` (minimum):
```
.env
*.env
```

`.env` (root, gitignored):
```
RESEND_API_KEY=re_xxxx
SEND_TO_EMAIL=owner@example.com
SEND_FROM_EMAIL=noreply@{domain}
```

For single-container (nginx + php-fpm) stacks, `.env` and `.gitignore`
live in the project root, not in an `api/` subdirectory.

## Project port allocation

Track in a single shared file. New projects pick the next available number.

```
8092  andrecobham.com
8094  backforge.nl
8095  ...
8096  floorithardwoodfloors.com
8097  next available
```

## Putting it behind a real domain

In production (a VM with a public IP), add a reverse proxy in front of the
container — typically Caddy or another nginx instance — that handles TLS
termination via Let's Encrypt and routes by `Host` header to the right
container port.

`Caddyfile` example:
```
{domain}.com {
    reverse_proxy localhost:{port}
}
```

Caddy auto-issues and renews TLS certs.

---

## Path B — cPanel + Apache (shared hosting)

### Key rule: repo path ≠ webroot

cPanel Git requires an EMPTY directory as the repository path.
The webroot (`public_html/{domain}/`) is never the repo path — cPanel
rejects it if it contains any files.

```
Repo path (empty):   /home/{username}/repositories/{domain}/
Webroot (served):    /home/{username}/public_html/{domain}/
```

### .cpanel.yml (ship in every project)

```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/{username}/public_html/{domain}/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```

Adjust folder list to match the project. Never copy: `tools/`, `*.py`,
`*.md`, `.git/`, `Dockerfile`, `docker-compose.yml`, `nginx.conf`.

### .htaccess (ship in every project)

Apache/cPanel needs `.htaccess` for clean URLs and error pages.
Place at project root — cPanel copies it to webroot via `.cpanel.yml`:

```apache
Options -Indexes
RewriteEngine On

# Directory-style URLs: /services/carpet-cleaning/ → index.html inside that folder
# Apache handles this automatically with DirectoryIndex — no extra rules needed

# Deny sensitive files
<FilesMatch "\.(py|yml|yaml|md|log|sh|env|conf|dockerfile)$">
    Order allow,deny
    Deny from all
</FilesMatch>

ErrorDocument 404 /404.html
ErrorDocument 500 /500.html
```

### Deploy flow (cPanel)

1. Push to GitHub remote
2. cPanel → Git Version Control → Manage → Pull or Deploy
3. cPanel runs `.cpanel.yml` tasks, copying files to webroot
4. Apache serves from webroot — no restart needed

### Cache busting on cPanel

Apache does not auto-invalidate cached assets. Bump `?v=N` on CSS/JS in
all HTML files after every asset change:

```html
<link rel="stylesheet" href="/assets/css/styles.css?v=6">
<script src="/assets/js/main.js?v=3"></script>
```

Increment by 1 on every change. Apply across ALL HTML pages.

### Verify after cPanel deploy

```bash
curl -s -o /dev/null -w "home: %{http_code}\n" https://{domain}/
curl -s -o /dev/null -w "css:  %{http_code}\n" https://{domain}/assets/css/styles.css
curl -s -o /dev/null -w "404:  %{http_code}\n" https://{domain}/page-that-does-not-exist
```

---

## Universal project checklist (both paths)

Every project must include ALL of these before first deploy:

```
Dockerfile           ✓  Docker/VPS
docker-compose.yml   ✓  Docker/VPS
nginx.conf           ✓  Docker/VPS
.htaccess            ✓  cPanel/Apache
.cpanel.yml          ✓  cPanel Git
.dockerignore        ✓  Docker build security
.gitignore           ✓  keeps .env and secrets out of git
robots.txt           ✓  both paths
sitemap.xml          ✓  both paths
404.html             ✓  both paths
```

Lahrcarpetcleaning.com is the reference implementation for both paths.


number: 08
filename: 08b-deployment-cpanel.md
title: 08b — Deployment (cPanel Git Version Control)
updated_at: 2026-05-24T14:17:08.822114
full_content:
# 08b — Deployment (cPanel Git Version Control)

Use this deployment method when the client's host is cPanel-based (shared hosting,
WHM, Bluehost, HostGator, SiteGround, etc.) instead of a VPS running Docker.

## Key rule: repo path ≠ webroot

cPanel Git requires an EMPTY directory as the repository path. The webroot
(`public_html/{domain}/`) is never the repo path — cPanel rejects it if it
already contains files.

```
Repo path (empty dir):    /home/{username}/repositories/{domain}/
Deploy target (webroot):  /home/{username}/public_html/{domain}/
```

## Setting up the repo in cPanel

1. cPanel → Git Version Control → Create Repository
2. Repository Path: `/home/{username}/repositories/{domain}/` (must be empty)
3. Clone URL: your Git remote (GitHub, Bitbucket, etc.)
4. cPanel clones into the repo path — never into the webroot

## .cpanel.yml

This file lives in the repo root and tells cPanel what to copy to the webroot
on every push/deploy. All paths are relative to the repo root.

```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/{username}/public_html/{domain}/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```

Add or remove folder cp lines to match the project's actual directory structure.
Do NOT copy: `tools/`, `*.py`, `*.md`, `.git/`, `docker-compose.yml`, `Dockerfile`.

## Lahrcarpetcleaning.com reference

```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/dev1communitypro/public_html/lahrcarpetcleaning.dev1.communityproud.com/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```

## Deploying after a push

1. Push to the connected remote (GitHub)
2. cPanel → Git Version Control → Manage → Pull or Deploy
3. cPanel runs the `.cpanel.yml` tasks, copying files to webroot
4. Apache serves from webroot automatically — no nginx, no Docker

## Apache vs nginx

cPanel hosts use Apache (not nginx). There is no nginx.conf to manage.
URL routing is handled by `.htaccess`:

```apache
Options -Indexes
RewriteEngine On

# Remove .html extension
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([^.]+)$ $1.html [NC,L]

# Redirect trailing slash to clean URL
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [R=301,L]

ErrorDocument 404 /404.html
```

For directory-style URLs (`/services/carpet-cleaning/`), the above is not
needed — Apache serves `index.html` inside each folder automatically when
`DirectoryIndex index.html` is set (the cPanel default).

## What NOT to copy to webroot

Never copy these to `DEPLOYPATH`:
- `tools/` — Python generation scripts, sensitive
- `*.py` — build/generation scripts
- `*.md` — documentation
- `docker-compose.yml`, `Dockerfile`, `nginx.conf`
- `.git/`, `.gitignore`, `.cpanel.yml` itself
- `assets/videos/hero/*.mp4` (optional) — large files; upload separately via FTP if needed

## Cache busting on cPanel

cPanel does not handle cache headers automatically. Add version query strings
to CSS and JS references in HTML after every update:

```html
<link rel="stylesheet" href="/assets/css/styles.css?v=6">
<script src="/assets/js/main.js?v=3"></script>
```

Increment `v=` by 1 each time the file changes. Update across ALL HTML files.

## Verify after deploy

```bash
# From local machine — check key paths return 200
curl -s -o /dev/null -w "home:    %{http_code}\n" https://{domain}/
curl -s -o /dev/null -w "css:     %{http_code}\n" https://{domain}/assets/css/styles.css
curl -s -o /dev/null -w "404:     %{http_code}\n" https://{domain}/nonexistent-page
```

All public paths 200, all non-existent paths 404.


number: 09
filename: 09-domain-email-dns.md
title: 09 — Domain, Email, DNS
updated_at: 2026-05-24T14:17:08.822170
full_content:
# 09 — Domain, Email, DNS

How to set up Resend for sending form-submission emails from `webleads@{domain}`
or similar, plus the DNS records that keep them out of spam.

## Resend account setup

1. Sign up at https://resend.com
2. Generate an API key (one per project): https://resend.com/api-keys
3. Save the key in the project's `api/.env` as `RESEND_API_KEY=re_xxxx`
4. NEVER commit `.env`. NEVER paste the key in Slack, GitHub, or chat logs.

## Add and verify the sending domain

1. https://resend.com/domains → **Add Domain**
2. Enter the domain (the one you'll send FROM, not necessarily the website domain)
3. Resend gives 3-4 DNS records. Add them all in Cloudflare (or whatever DNS host)
4. Wait 5-15 minutes, click **Verify** in Resend — all records must show green

### Records Resend provides

| Type | Name | Value | Proxy | TTL |
|------|------|-------|-------|-----|
| TXT | `resend._domainkey` | `p=...long-rsa-key...` | DNS only | 1 hr |
| TXT | `send` | `v=spf1 include:amazonses.com ~all` | DNS only | 1 hr |
| MX  | `send` | `feedback-smtp.{region}.amazonses.com` priority 10 | DNS only | 1 hr |

(Resend uses Amazon SES under the hood, hence `amazonses.com` in the SPF.)

## DMARC — REQUIRED for inbox placement

Without DMARC, Gmail flags otherwise-correctly-configured email as suspicious
and routes it to spam. Resend doesn't auto-create this record. You must add it.

| Type | Name | Value | Proxy | TTL |
|------|------|-------|-------|-----|
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dev@{domain}` | DNS only | Auto |

Components:
- `v=DMARC1` — declares a DMARC policy exists
- `p=none` — monitor mode, doesn't reject anything yet (safe to start)
- `rua=mailto:...` — DMARC failure reports go to this inbox (review weekly)

After 30 days of clean DMARC reports with no false positives, optionally
upgrade to `p=quarantine` then `p=reject`.

## Verify DNS is live

```bash
dig +short TXT resend._domainkey.{domain} @8.8.8.8
dig +short TXT send.{domain} @8.8.8.8
dig +short TXT _dmarc.{domain} @8.8.8.8
dig +short MX send.{domain} @8.8.8.8
```

All four should return their expected values.

## From-name format

Always use a friendly From name, not bare email. Bare email looks robotic
and triggers spam filters.

```
FROM_EMAIL=Brand Name <webleads@{domain}>
```

## TO-email setup

The `TO_EMAIL` is wherever the lead actually goes. Often a Gmail group address
or the owner's personal inbox.

- During Resend domain verification (BEFORE green): you can ONLY send TO the
  email tied to the Resend account
- After verification: send to anyone

For local testing without verification, use:
```
FROM_EMAIL=onboarding@resend.dev
TO_EMAIL={your-resend-account-email}
```

## When emails go to spam

Run this checklist:

1. **All 4 DNS records green at Resend**? If not, deliverability suffers.
2. **DMARC TXT record exists**? Most common cause of spam folder.
3. **Friendly From name**? `Brand Name <webleads@...>` not bare `webleads@...`
4. **Both `html` and `text` parts in the payload**? HTML-only is suspicious.
5. **Subject line clean**? No em-dashes, no "Estimate Request URGENT", no all-caps.
6. **Recipient marked first emails as Not Spam**? Train Gmail.

## Resend regional considerations

Resend operates from `us-east-1` (Virginia) by default. For email routing this
is fine globally, but if you need EU residency the `region` field at domain
creation accepts `eu-west-1`. Use this for clients in Europe.

## When to use SMTP instead of REST API

Resend supports both SMTP and REST API. We use the **REST API** because:
- Simpler from Python (urllib HTTPS POST, no smtplib state)
- Better error responses (JSON with `statusCode` and `message`)
- Idempotency-Key header support for deduplication
- Faster (HTTP keepalive vs SMTP handshake)

The SMTP path exists if a 3rd-party tool needs it (e.g., a CRM that only
speaks SMTP), but our Python service uses REST.

## Cloudflare-specific notes

The user-agent quirk (see `07-form-handling.md`) — Cloudflare in front of
Resend's API blocks Python's default `User-Agent: Python-urllib/3.x`. Always
set a custom `User-Agent` in the API request headers.

If the DNS provider is Cloudflare, ensure all Resend records have **proxy
status: DNS only** (the gray cloud icon, not orange). Proxying these breaks
authentication.

## Annual key rotation

Rotate Resend API keys annually:
1. Generate new key in Resend dashboard
2. Update `api/.env` on the server
3. `docker compose down && docker compose up -d` to reload env
4. Confirm a test submission still works
5. Revoke the old key in Resend dashboard

## Resend HTTP 403 — Domain Not Verified

A 403 from the Resend API does NOT mean the API key is wrong. The specific
error is:

```json
{"statusCode":403,"message":"The {domain} domain is not verified.
Please, add and verify your domain on https://resend.com/domains","name":"validation_error"}
```

This means the key is valid and authenticated, but the FROM domain has not
been added or verified at resend.com/domains yet.

Rule: **verify the domain BEFORE testing the form endpoint.** If you test
before verification, `{"ok":false}` will be returned to the visitor even
though the API key is correct and the code is correct.

Sequence:
1. Set `RESEND_API_KEY` in `.env`
2. Add domain at resend.com/domains
3. Add DNS records in Cloudflare
4. Wait for green verification
5. Then test the form endpoint

---

## DKIM key rotation

Resend periodically rotates DKIM keys. They send email when this happens. Add
the new `resend2._domainkey` (or whichever selector they specify) TXT record
in Cloudflare, then click verify. Old key remains active until they remove it.

---

## SMTP Relay via msmtp (Docker containers with cPanel hosting)

When a project uses Docker and the client has cPanel hosting email (e.g., `mail.ccgmail.us`), use `msmtp` as the sendmail replacement inside the container instead of the Resend API.

### Dockerfile additions

```dockerfile
RUN apk add --no-cache msmtp ca-certificates
```

Add to `infra/docker-entrypoint.sh`:

```sh
#!/bin/sh
if [ -n "${SMTP_HOST}" ]; then
    cat > /etc/msmtprc <<EOF
defaults
auth           on
tls            on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile        /dev/stderr

account        default
host           ${SMTP_HOST}
port           ${SMTP_PORT:-587}
from           ${MAIL_FROM:-noreply@domain.com}
user           ${SMTP_USER}
password       ${SMTP_PASS}
EOF
    chmod 600 /etc/msmtprc
fi
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
```

Set in `infra/php.ini`:
```ini
sendmail_path = /usr/bin/msmtp -a default -t
```

### Required .env vars

```
SMTP_HOST=mail.{cpanel-host}.com
SMTP_PORT=587
SMTP_USER=noreply@{domain}
SMTP_PASS={cPanel email password}
MAIL_FROM=noreply@{domain}
MAIL_FROM_NAME={Brand}
```

### Spam filter headers (PHP mail())

Always include `Date:` and `Message-ID:` in the headers — missing headers trigger spam filters:

```php
$message_id = '<' . time() . '.' . bin2hex(random_bytes(8)) . '@{domain}';
$headers = 'From: Brand <noreply@domain>' . "\r\n"
    . 'Date: ' . date('r') . "\r\n"
    . 'Message-ID: ' . $message_id . "\r\n"
    . 'MIME-Version: 1.0' . "\r\n";
```

### When to use Resend vs msmtp

| Situation | Use |
|-----------|-----|
| Client has no cPanel/SMTP credentials | Resend API |
| Client has cPanel hosting with email | msmtp + cPanel SMTP |
| Marketing/bulk email | Resend (SES infrastructure, better deliverability at scale) |
| Transactional only (magic links, download links) | Either — msmtp simpler if SMTP exists |


number: 10
filename: 10-testing-verification.md
title: 10 — Testing and Verification
updated_at: 2026-05-24T14:17:08.822246
full_content:
# 10 — 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)

Save in `.planning/mobile_check.py`:

```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


number: 11
filename: 11-healthcare-regulated-credentials.md
title: SOP 11 — Healthcare & Regulated Credential Copy
updated_at: 2026-05-24T14:17:08.822306
full_content:
# SOP 11 — Healthcare & Regulated Credential Copy

## WHY THIS EXISTS

Misrepresenting a clinician's licensure status on a public website violates New York State credentialing rules and can trigger disciplinary action from NYSED or the relevant licensing board. The cost of getting this wrong is not a correction — it is a formal complaint. This SOP exists so no Arising Media site ever publishes legally inaccurate credential copy.

---

## Credential Tier Definitions (New York State)

| Credential | Abbreviation | Public Label | Correct Framing |
|---|---|---|---|
| Licensed Mental Health Counselor | LMHC | Licensed | "Licensed Mental Health Counselor" |
| LMHC with Doctorate | LMHC-D | Licensed | "Licensed Mental Health Counselor, Doctoral Level" |
| Mental Health Counselor, Limited Permit | MHC-LP | Provisionally Licensed | "Provisionally Licensed Mental Health Counselor" |
| Licensed Clinical Social Worker | LCSW | Licensed | "Licensed Clinical Social Worker" |
| Licensed Master Social Worker | LMSW | Licensed | "Licensed Master Social Worker" |

**MHC-LP is NOT "Licensed Professional."** LP stands for Limited Permit. The correct public-facing term is **Provisionally Licensed**.

---

## Rules — Apply to Every Regulated Healthcare Site

### 1. Never use "Licensed" for a permit holder
- MHC-LP, LMSW-candidate, or any other permit/pre-license designation must use "Provisionally Licensed" on the public site.
- Check: if the credential ends in `-LP`, `-candidate`, `-trainee`, or includes "Limited Permit" — it is not a full license.

### 2. Credential label on credentials row
- Full license holders → label: `License`
- Permit/provisional holders → label: `Permit`
- When in doubt, use `Credential`

### 3. Supervision disclosure is mandatory
- Any provisionally licensed or permit-holding clinician must display: `Practices under the supervision of [Name], [Credential]`
- This belongs in the footer disclaimer, the about page credentials row, and the auto-response email.
- Supervisor's credential must be accurate to the letter. LMHC ≠ LMHC-D. Always confirm the current designation before publishing.

### 4. Practice entity in the footer
- The footer disclaimer must state the legal practice entity name.
- A Vibrant You Counseling-style site shows the clinician's brand as the primary identity. The supervising PLLC may appear in the disclaimer only — not as a tagline or secondary brand.
- Do not add "A practice of [PLLC name]" as a visible footer tagline unless the client explicitly requests it.

### 5. "Provisionally Licensed" vs "Licensed" — when in doubt
- Use "Provisionally Licensed" for permit holders everywhere: credential row, why-cards, location page intros, section headers, auto-response emails, meta descriptions.
- Pattern for location/intro copy: `[Name], MHC-LP, is provisionally licensed in New York State and practices under the supervision of [Supervisor], [Credential].`

---

## Credential Verification Checklist (Pre-Launch)

- [ ] Confirmed clinician's current credential abbreviation directly with client
- [ ] Confirmed supervisor's current credential abbreviation directly with client
- [ ] No instance of "Licensed Professional" where LP = Limited Permit
- [ ] No instance of bare "Licensed" applied to a permit holder
- [ ] Footer disclaimer includes full supervision disclosure
- [ ] About page credential row uses correct label (License vs Permit)
- [ ] Auto-response email includes supervision disclosure
- [ ] All 40+ generated pages (location, service, definitions) use correct framing
- [ ] Run: `grep -r "Licensed Professional" --include="*.html"` — result must be zero

---

## Common Mistakes

| Wrong | Correct |
|---|---|
| MHC-LP, Mental Health Counselor, Licensed Professional | MHC-LP, Provisionally Licensed Mental Health Counselor |
| Licensed in New York State (for MHC-LP) | Provisionally licensed in New York State |
| Licensed & Specifically Trained (why-card for MHC-LP) | Provisionally Licensed & Specifically Trained |
| `<strong>License</strong>` (credential row for permit holder) | `<strong>Permit</strong>` |
| Ryan Sforza, LMHC (when designation is LMHC-D) | Ryan Sforza, LMHC-D |

---

## When a Credential Changes

Supervisor upgrades (LMHC → LMHC-D) or clinician advances (MHC-LP → LMHC) happen. When notified:

1. Run the bulk replace Python pattern from SOP 03 to update all HTML, PHP, PY, JSON files.
2. Update build/render.py and build/copy_library.py sources first — these regenerate the pages.
3. Rebuild the Docker container.
4. Re-run the grep verification check above.
5. Log the change in hop.db.


number: 12
filename: 12-image-assets.md
title: 12 — Image Assets (WebP, Sizing, Generation)
updated_at: 2026-05-24T14:17:08.822376
full_content:
# 12 — Image Assets (WebP, Sizing, Generation)

All images shipped to production must be WebP. No JPGs, no PNGs in the webroot.

## Format rule: WebP only

- Every image in `assets/images/` is `.webp`
- Original JPGs and PNGs are generated/sourced then converted — they do NOT
  go into the repo or the Docker image
- HTML always references `.webp` paths, never `.jpg` or `.png`
- If an original JPG is found in the repo, run `tools/convert-to-webp.py`
  and delete the source file

## Size targets

| Image type | Max width | WebP quality | Target file size |
|---|---|---|---|
| Service card / thumbnail | 900px | 78 | 30–80KB |
| Hero image (page header) | 1400px | 80 | 50–180KB |
| OG / social share image | 1200px | 85 | under 150KB |

Images over these targets will be rejected at deploy time (visual review).

## Conversion tool

Every project has `tools/convert-to-webp.py`. Run it after adding or
regenerating any images:

```bash
cd /home/sirdrez/arisingmedia-websites/{domain}
python3 tools/convert-to-webp.py
```

What it does:
1. Scans `assets/images/services/` and `assets/images/hero/` for `.jpg` files
2. Resizes to max width (900px services, 1400px hero)
3. Saves `.webp` at target quality
4. Updates all `.html` files in the project to reference `.webp` instead of `.jpg`
5. Prints a before/after KB report

After running, manually delete the source `.jpg` files:
```bash
rm assets/images/services/*.jpg assets/images/hero/*.jpg
```

Then rebuild Docker or re-deploy so the new files are served.

## Hero images — one per page

Every page that uses a page-hero section gets its own unique hero image.
No two pages share the same hero. Naming convention:

```
hero-{page-slug}.webp
```

Examples:
```
hero-carpet-cleaning.webp    → services/carpet-cleaning/
hero-stairs.webp             → services/stairs/
hero-upholstery.webp         → services/upholstery/
hero-floors.webp             → services/floors/
hero-area-rugs.webp          → services/area-rugs/
hero-add-ons.webp            → services/add-ons/
hero-commercial.webp         → services/commercial/
hero-offices.webp            → commercial/offices/
hero-vacation-rentals.webp   → commercial/vacation-rentals/
hero-hotels.webp             → commercial/hotels-inns/
hero-retail.webp             → commercial/retail-showrooms/
hero-property-management.webp → commercial/property-management/
hero-about.webp              → about/
hero-service-area.webp       → service-area/
```

## Image content rules

- No people, no faces
- No cleaning machines, vacuums, steam equipment
- No text overlaid on source images
- Scene-based: show the RESULT (clean room, clean carpet, clean office)
  not the process (machine, worker, before state)
- Residential pages: warm home interiors — living rooms, stairs, bedrooms
- Commercial pages: professional spaces — offices, hotel corridors, lobbies

## Local image generation (ComfyUI + FLUX.1 Schnell)

When Google Imagen API is unavailable, generate locally via ComfyUI.

### Why FLUX.1 Schnell

FLUX is a 12B-parameter transformer model (vs SDXL's 3.5B). It has significantly
better spatial understanding: correct depth-to-space ratio, realistic lens
simulation, proper scene geometry. RealVisXL V5.0 (SDXL-based) was tested and
rejected — flat camera angles, no depth, poor spatial coherence.

### Model stack (installed at `~/ComfyUI/`)

| File | Location | Size | Purpose |
|---|---|---|---|
| `flux1-schnell-Q8_0.gguf` | `models/unet/` | 12GB | Main model (GGUF Q8_0) |
| `t5xxl_fp8_e4m3fn.safetensors` | `models/clip/` | 4.6GB | T5-XXL text encoder (long prompts) |
| `clip_l.safetensors` | `models/clip/` | 235MB | CLIP-L text encoder (short prompts) |
| `ae.safetensors` | `models/vae/` | 108MB | Official FLUX VAE (Black Forest Labs) |

Custom node required: `custom_nodes/ComfyUI-GGUF/` (handles GGUF quantized UNet)

### VAE note

`ae.safetensors` is from `black-forest-labs/FLUX.1-schnell` on HuggingFace.
The repo is gated — requires HF login AND accepting the license.
Use `hf auth login` with a read token before downloading:
```bash
HF_TOKEN=$(cat ~/.cache/huggingface/token)
wget --header="Authorization: Bearer $HF_TOKEN" \
  "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors" \
  -O ~/ComfyUI/models/vae/ae.safetensors
```

### ComfyUI workflow (what gen-images-flux.py sends)

```
UnetLoaderGGUF (flux1-schnell-Q8_0.gguf)
        |
DualCLIPLoader (t5xxl_fp8_e4m3fn + clip_l, type=flux)
        |
CLIPTextEncode (prompt)
        |
EmptyLatentImage (1024×576, batch=1)
        |
KSampler (steps=4, cfg=1.0, sampler=euler, scheduler=simple)
        |
VAEDecode (ae.safetensors)
        |
SaveImage → /ComfyUI/output/flux_lahr_*.png
```

### Hardware requirements

FLUX Q8_0 loads ~12GB into RAM (CPU mode). GPU requires 12GB+ VRAM.
With 2GB VRAM, run `--cpu` flag — ~4 min per image on CPU.

### Starting ComfyUI

```bash
# Start in tmux (stays alive after terminal closes)
tmux new-session -d -s comfyui \
  "cd ~/ComfyUI && venv/bin/python main.py --listen 0.0.0.0 --port 8188 --cpu 2>&1 | tee ~/comfyui.log"

# Wait for startup (~30s), verify:
curl -s http://localhost:8188/system_stats | python3 -m json.tool | head -5
```

### Running generation

```bash
cd /home/sirdrez/arisingmedia-websites/{domain}
python3 tools/gen-images-flux.py 2>&1 | tee tools/flux-gen.log
```

Monitor progress:
```bash
tmux attach -t comfyui      # see step progress bars
tail -f tools/flux-gen.log  # see per-image OK/FAIL
```

### Prompt strategy for interior/carpet photography

All prompts use explicit camera and lens language so FLUX renders correct depth:

```
low-angle 35mm lens perspective looking across [subject] in foreground,
[subject] razor sharp in foreground, [background elements] receding into bokeh,
shallow depth of field, [light description], no people, ultra-realistic [type] photography
```

Key terms that improve spatial rendering:
- `low-angle 35mm lens perspective` or `low 24mm wide-angle`
- `sharp in foreground ... receding into bokeh`
- `shallow depth of field`
- `vanishing point perspective` for corridors and offices

### After generation completes

```bash
python3 tools/convert-to-webp.py
rm assets/images/services/*.jpg assets/images/hero/*.jpg
docker compose build --no-cache web && docker compose up -d
```

## vibrantyou.yoga specifics (2026-05-17)

Remote ComfyUI machine: 10.20.10.10:8188
Model: flux1-schnell-Q4_K_S.gguf (Q4, smaller than Q8 — CPU only, ~5 min/image)
ROCm note: gfx1103 (Radeon 780M) segfaults on fp8 safetensors in samplers.py. GGUF only.
Restart ComfyUI: `/opt/ComfyUI/venv/bin/python3 /opt/ComfyUI/main.py --listen --port 8188 --cpu &`

Generation strategy:
- 4 variants per image slot (28 total)
- No people — props, interiors, flat lays only
- Output to `assets/images/generated/{name}_v{1-4}.webp`
- Pick best variant, copy to `assets/images/{final-name}.webp`
- Script: `.planning/generate_images.py`

Yoga image content rules:
- No people, no faces, no body parts
- Yoga props: mats, blocks, straps, bolsters, cushions, candles, singing bowls
- Studio interiors: wooden floor, plants, natural light, sage green/white tones
- Flat lay overhead or close-up detail shots
- Lifestyle: mat + laptop, mat + plant, mat + props

## After adding images — checklist

1. Run `tools/convert-to-webp.py`
2. Delete source `.jpg` files
3. Bump CSS/JS cache version in all HTML (`?v=N`)
4. Rebuild Docker (`docker compose build --no-cache web && docker compose up -d`)
5. Verify: `curl -s -o /dev/null -w "%{http_code}" http://localhost:{port}/assets/images/hero/hero-carpet-cleaning.webp`
   — must return 200
6. Verify old `.jpg` returns 404


number: 13
filename: 13-reference-site-visual-audit.md
title: SOP 13 — Reference-Site Visual Audit
updated_at: 2026-05-24T14:17:08.822447
full_content:
# SOP 13 — Reference-Site Visual Audit

Use this SOP whenever a client (or Andre) cites a reference site we should learn the layout, scroll behavior, and content rhythm from. The output is a skeleton + scroll-transition map we can paste into our own templates as slot definitions.

Authoritative example: `arisingmedia.us/.planning/MINISFORUM_REFERENCE.md` + `.planning/minisforum-reference/` + `.planning/MINISFORUM_VISUAL_REPORT.html`.

## Why we do this
Single full-page screenshots flatten sticky and pinned scroll behavior. We need motion captured as video plus frame-by-frame stills to see where sections actually transition. Without this we end up with flat white templates that look nothing like the reference.

## Inputs
- Reference URL (single canonical product/landing page is enough)
- Output folder: `<project>/.planning/<refname>-reference/`

## Outputs (every audit must produce all six)
1. `<refname>-reference/scroll-capture/desktop-scroll.webm` — full scroll, 1440×900, slow (≈30s).
2. `<refname>-reference/scroll-capture/mobile-scroll.webm` — full scroll, 390×844.
3. `<refname>-reference/scroll-capture/frames/desktop/frame_*.png` — every 250px scrollY.
4. `<refname>-reference/scroll-capture/frames/mobile/frame_*.png` — every 250px scrollY.
5. `<refname>-reference/scroll-capture/frames/desktop-annotated/T01..Tn_*.png` — before/during/after for every detected pin-spacer or sticky transition.
6. `MINISFORUM_REFERENCE.md` (rename for the site) — page framing, section inventory, typography, color, imagery strategy, scroll effects, mobile-vs-desktop, components-to-borrow, mapping to our schema columns.

Plus one rolled-up HTML report (`<REFNAME>_VISUAL_REPORT.html`) with the videos and frame strips embedded so Andre can open one file in Firefox.

## Browser policy
Firefox only. Playwright `firefox.launch(headless=False)` for verification screenshots. Never chromium.

## Capture procedure (Playwright Firefox)
1. Goto URL, wait `networkidle`.
2. Get full document height `document.scrollingElement.scrollHeight`.
3. Start `page.video` recording (record_video_dir set on context).
4. Scroll loop: from 0 to scrollHeight, step 8–12 px, sleep 16 ms — yields a smooth ~30s video.
5. At every 250 px boundary save a screenshot to `frames/desktop/frame_<scrollY>.png`.
6. Detect transitions: for every `aside`, `[data-section]`, `.shopify-section`, `.pin-spacer`, capture `getBoundingClientRect()` + `position` + `top` at every step. A transition is logged when `position` changes (e.g. relative→sticky→relative) or when a parent pin-spacer height changes. Write the list to `frames/scroll-events.json`.
7. For each detected transition T(n), save three annotated frames at scrollY-200, scrollY, scrollY+200 with overlay text `Tn: <element-selector>` and `y=<scrollY>` to `frames/desktop-annotated/`.
8. Repeat 1–7 at 390×844 for mobile.

## Skeleton extraction
For the canonical reference page produce:
- z-index map (sticky + fixed elements, with px heights)
- pinned-zone inventory: count, scroll distance held, panel count per zone
- inter-section spacing rhythm
- color band rhythm (which sections share a bg color, which alternate)
- typography scale (h1/h2/h3/body in px and line-height)
- imagery slot list (hero bg, hero illustration, trust logos, pin sticky images, deliverable icons, spec icons, testimonial avatars, og defaults)

## Schema mapping
Every distinct section in the reference must be assigned to one of our schema columns or get a new column added in a versioned migration (`build/migrations/YYYY_MM_DD_schema_vN.sql`). The skeleton is then the contract: every page type declares which slots are active, the renderer skips null slots silently.

## Application gates
- Designs land in `.planning/WIREFRAME_SKETCH_<DATE>.md` first (ASCII skeleton with scrollY ranges).
- CSS additions live in a new section of `main.css` titled `/* === Section bands & scroll-reveal (ref: <REFNAME>) === */`.
- JS additions to `main.js` only for IntersectionObserver-driven reveals; GSAP ScrollTrigger only if pin zones are required.
- Verify with `verify-protection.sh` after deploy — the audit folder is `.planning/` only, never shipped to `src/`.

## Anti-patterns (do not do)
- Single full-page screenshot for a long-scroll page (loses sticky/pin info).
- Chromium / chrome / chrome-headless captures (per CLAUDE.md).
- Inventing slots that the reference does not have just because we want them.
- Touching `Dockerfile`, nginx config, or shipping the `.planning/<refname>-reference/` folder to production.

## Done definition
Andre can open `<REFNAME>_VISUAL_REPORT.html` in Firefox and (a) see both scroll videos play, (b) page through every T(n) annotated triplet, (c) read the skeleton, (d) see the mapping table from reference section → our schema column.


number: 14
filename: 14-php-app-stack.md
title: 14 — PHP App Stack (Server-Side Processing)
updated_at: 2026-05-24T14:17:08.822495
full_content:
# 14 — PHP App Stack (Server-Side Processing)

Use this pattern when a project requires server-side processing that static HTML cannot handle: file conversion, at-rest encryption, payment processing, user authentication, or API-gated features.

**Reference implementation:** `quickconvert.us`

---

## When to use this pattern instead of static HTML

- File uploads and processing (image conversion, PDF generation, etc.)
- At-rest encryption of user data
- Payment processing with Stripe subscriptions
- User authentication with magic link or password-based login
- Rate-limited APIs that must be server-enforced

**Do not** introduce this pattern just to add a contact form. Use the Python stdlib form service from SOP 07 instead.

---

## Stack

- **PHP 8.3** (php:8.3-fpm-alpine base image)
- **Nginx** (Alpine package, same container via supervisord)
- **SQLite** (pdo_sqlite extension, no separate DB container needed)
- **libsodium** (built into PHP 8.x — use for all encryption)
- **ImageMagick** (pecl imagick for image processing)
- **msmtp** (SMTP relay for outbound email — see SOP 09)
- **supervisord** (manages nginx + php-fpm + crond in one container)

---

## Project structure

```
project/
├── src/                    ← nginx document root
│   ├── index.php
│   ├── api/
│   │   ├── convert.php     ← POST endpoint (CSRF + reCAPTCHA protected)
│   │   └── download.php    ← GET endpoint (signed token)
│   ├── assets/css/
│   ├── assets/js/
│   └── assets/images/
├── includes/               ← PHP classes (above doc root, not web-accessible)
│   ├── bootstrap.php       ← constants, session, autoload
│   ├── auth.php            ← login, register, magic token
│   ├── csrf.php
│   ├── db.php              ← SQLite PDO wrapper
│   ├── encryption.php      ← libsodium wrappers
│   └── mailer.php
├── components/
│   ├── header.php
│   └── footer.php
├── storage/                ← volume-mounted, NOT in docker image
│   ├── uploads/            ← encrypted .enc files only
│   ├── converted/
│   ├── temp/
│   ├── .htaccess           ← deny all direct access
│   └── {app}.db
├── infra/
│   ├── nginx.conf
│   ├── php.ini
│   ├── supervisord.conf
│   └── docker-entrypoint.sh
├── tools/
│   └── cleanup.php         ← cron: delete expired tokens + files
├── Dockerfile
├── docker-compose.yml
└── .env                    ← gitignored, never committed
```

---

## Security requirements (non-negotiable)

**CSRF** — every POST form and API endpoint must verify a CSRF token tied to the session.

**Rate limiting** — two layers:
1. nginx: `limit_req_zone` on /api/ (10 req/s, burst 20)
2. PHP: per-IP daily counter in SQLite rate_limits table

**reCAPTCHA v3** — on conversion/upload endpoints. Verify server-side via Google API. Cache result in session (verify once per session, not per request).

**At-rest encryption** — any user-uploaded file must be encrypted before writing to disk. Use `sodium_crypto_secretstream_xchacha20poly1305_*` for files, `sodium_crypto_secretbox` for strings. Key stored in `.env` as `QC_ENCRYPTION_KEY` (32 bytes hex).

**Signed download tokens** — never expose file paths. Issue a 64-char hex token stored in SQLite with expiry and single-use enforcement.

**Magic link auth** — prefer magic link over password. On register: create account unverified, send verify email, block login until verified. Token: 64-char hex, 1-hour expiry, stored in `magic_tokens` table, consumed on use.

---

## nginx security headers (required)

```nginx
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header X-Content-Type-Options    "nosniff" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Permissions-Policy        "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy   "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; form-action 'self' https://checkout.stripe.com;" always;

# Stripe webhook — POST only
location = /api/stripe-webhook.php {
    limit_except POST { deny all; }
}

# Block dotfiles
location ~ /\. { deny all; return 403; }
```

---

## Database schema pattern (SQLite, idempotent)

Use `CREATE TABLE IF NOT EXISTS` for all tables. Use `ALTER TABLE ... ADD COLUMN` wrapped in try/catch for schema migrations — SQLite does not support `IF NOT EXISTS` on columns.

```php
try { $pdo->exec("ALTER TABLE users ADD COLUMN verified_at INTEGER DEFAULT NULL"); }
catch (Throwable $e) { /* column already exists */ }
```

---

## Stripe integration

- Checkout: create session server-side, redirect to Stripe-hosted page
- Webhook: verify `Stripe-Signature` header using HMAC-SHA256 (implement without Stripe SDK — use curl)
- Webhook tolerance: 300 seconds (5 min) on timestamp
- Register webhook endpoint at: `https://{domain}/api/stripe-webhook.php`
- Events to subscribe: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`

---

## .env required vars

```
APP_ENV=production
BASE_URL=https://{domain}
QC_ENCRYPTION_KEY={32-bytes-hex}
STRIPE_MODE=live
STRIPE_LIVE_SECRET_KEY=sk_live_...
STRIPE_LIVE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...
RECAPTCHA_SITE_KEY=...
RECAPTCHA_SECRET_KEY=...
SMTP_HOST=...
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
MAIL_FROM=noreply@{domain}
MAIL_FROM_NAME={Brand}
```

Generate encryption key: `php -r "echo bin2hex(random_bytes(32));"`


# sop_sections

title: The one stack
content:
Every Arising Media site is built on the programmatic stack: PHP Router + standalone SQLite databases + PHP templates + data-driven components. There is no "pick a stack based on page count." All new sites and all migrations use this approach.

Stack B (static HTML) is legacy. Existing static sites (e.g., `lahrcarpetcleaning.com`) are maintained as-is until migrated. No new sites are started as static HTML.


title: The programmatic stack
content:
- **PHP Router** — `src/api/router.php` dispatches every URL to the correct PHP template. Edit one template = entire page class updates on next request.
- **PHP templates** — thin shells. They query the database, call `_header.php`, loop through sections, call `_footer.php`. No hardcoded copy ever lives in a PHP file.
- **Data components** — sections are typed JSON objects stored in `sections_json` on each page row. Templates loop through sections and render each type via a switch block. Content is in the database; structure is in the template.
- **Standalone SQLite databases** — one SQLite file per content domain, never one monolithic DB with all tables. Examples:
  - `pages.sqlite` — page metadata + `sections_json`
  - `glossary.sqlite` — terms, slugs, definitions, categories
  - `schedule.sqlite` — recurring classes, times, formats
  - `testimonials.sqlite` — quotes, authors, featured flag
  - `blog.sqlite` — posts, slugs, body HTML, tags
- **Why standalone databases** — a single SQLite file per domain is easier to query in isolation, faster to back up or swap, and avoids table-lock contention between unrelated content types. Never combine unrelated content domains into one DB to save files. The cost of an extra `.sqlite` is zero.
- **URL routing** — nginx maps URL patterns to `QUERY_STRING type=X&slug=Y` passed to `router.php`. Adding a page = insert a row in `pages.sqlite`. No nginx edit needed for standard slug patterns.
- **No static HTML page files** — no `about/index.html`, no `services/index.html`. All page content lives in SQLite. To find or update copy: `sqlite3 src/api/data/pages.sqlite`.
- **Vanilla JavaScript** — no frameworks. `fetch`, `IntersectionObserver`, `querySelector`.
- **Plain CSS** — `tokens.css` (design tokens) + `main.css` (components). No Sass, no Tailwind.
- **Docker** — `php:8.3-fpm-alpine` + nginx + supervisord in a single container. SQLite DBs baked into image at build time.
- **Resend** — transactional email via `src/api/contact.php` (PHP curl, not Python).
- **clear_env = no** — CRITICAL: `infra/php-fpm-pool.conf` must contain `clear_env = no` or `getenv()` returns empty string in PHP. Never remove this line.
- **Dev view** — `http://localhost:{port}/_devview` — split view: PHP router output (left), DB query trace (top right), live iframe (bottom right). Volume-mounted, no rebuild needed.
- **References:** `vibrantyou.yoga` (migrated 2026-05-22), `arisingmedia.us`


title: Directory structure
content:
```
{domain}/
├── src/
│   └── api/
│       ├── router.php              ← URL dispatcher (never edit for standard slugs)
│       ├── contact.php             ← form handler (Resend, PHP curl only)
│       ├── templates/              ← one .php per page type (thin shells, no copy)
│       ├── components/
│       │   ├── _header.php         ← DOCTYPE → <main> (nav, tokens, CSS links)
│       │   └── _footer.php         ← </main> → </html> (footer HTML + vanilla JS)
│       └── data/                   ← all SQLite files (seeded by build/seed_databases.py)
│           ├── pages.sqlite
│           ├── blog.sqlite
│           └── *.sqlite            ← one file per content domain
├── assets/
│   ├── css/
│   │   ├── tokens.css              ← design tokens (colors, spacing, type scale)
│   │   └── main.css                ← all components, layout, responsive breakpoints
│   ├── js/
│   │   └── main.js                 ← vanilla JS only — fetch, IntersectionObserver, querySelector
│   └── images/                     ← .webp only
├── build/
│   └── seed_databases.py           ← creates + seeds all SQLite DBs from source data
├── infra/
│   ├── nginx.conf                  ← URL routing rules → QUERY_STRING → router.php
│   ├── supervisord.conf            ← manages nginx + php-fpm in one container
│   └── php-fpm-pool.conf           ← MUST contain clear_env = no
├── .planning/                      ← never served, never in Docker image
│   ├── README.md                   ← project notes, content sources, decisions
│   ├── wireframe.html              ← design reference
│   ├── DNS_*.txt                   ← DNS records, Cloudflare config
│   └── review_*.png                ← design review screenshots
├── Dockerfile                      ← php:8.3-fpm-alpine, SQLite DBs baked in at build
├── docker-compose.yml              ← port mapping, env vars, volume mounts for dev
├── .cpanel.yml                     ← cPanel deployment (used when host is shared/cPanel)
├── .htpasswd                       ← basic auth for staging (never committed with real credentials)
├── .dockerignore
├── .gitignore                      ← .env, .htpasswd, *.sqlite (if seeded at runtime)
├── 404.html
├── 500.html
├── robots.txt
└── sitemap.xml
```


title: How a URL resolves
content:
```
GET /glossary/asana/
  → nginx matches location ~ ^/glossary/([a-z0-9-]+)/$
  → sets QUERY_STRING type=glossary_term&slug=asana
  → fastcgi_pass to router.php
  → router.php: type=glossary_term → require templates/glossary.php
  → glossary.php: SELECT * FROM glossary.sqlite WHERE slug='asana'
  → require _header.php → render term → require _footer.php
  → HTML response
```


title: Adding a new page
content:
1. Insert a row in `pages.sqlite`: slug, title, hero fields, sections_json
2. Re-seed: `python3 build/seed_databases.py`
3. Rebuild Docker: `docker compose build && docker compose up -d`
4. Standard slug (single word, no dynamic param): no nginx edit needed
5. New URL pattern: add location block to `infra/nginx.conf`


title: Components — mandatory pattern
content:
Every page is assembled from three layers:

1. `_header.php` — outputs `<!DOCTYPE html>` through `<main>`. Contains: nav links (from DB), CSS token/main links, meta tags. Never hardcode nav items in HTML; always query `nav.sqlite` or `pages.sqlite`.
2. Template file (`templates/{type}.php`) — queries its SQLite DB, loops `sections_json`, renders each section type via a `switch`. This is the only place rendering logic lives.
3. `_footer.php` — closes `</main>` through `</html>`. Contains: footer HTML, vanilla JS `<script>` tags, Resend form init if needed.

No other rendering pattern is used. Content flows: database → template → component frame → browser. Never the other way.


title: Deployment targets
content:
Two valid deployment targets. Both are supported per project:

- **Docker** (`Dockerfile` + `docker-compose.yml`) — standard for all VPS/VM hosting. Single container: `php:8.3-fpm-alpine` + nginx + supervisord. SQLite DBs baked into image at build time. Run: `docker compose build && docker compose up -d`.
- **cPanel** (`.cpanel.yml`) — used when client host is shared/cPanel (e.g., Namecheap, GoDaddy). `.cpanel.yml` defines deploy path. PHP and SQLite available on host. No Docker needed.

Both targets use the same source. `.cpanel.yml` is always present in the project root even if Docker is the primary target, so the project is portable.


title: Staging password protection
content:
Staging environments use HTTP basic auth via `.htpasswd` + nginx `auth_basic` block. Never deploy a staging URL without password protection. The `.htpasswd` file is gitignored. Credentials are stored in 1Password under the project name. When a site goes live, remove the `auth_basic` block from `nginx.conf` and rebuild.


title: .planning/ — build notes and planning artifacts
content:
Everything that is not served to the browser lives in `.planning/`. This includes:

- `README.md` — project notes, content sources, client decisions, port number
- Wireframes, design references, screenshots
- DNS records and Cloudflare config notes
- Seed data CSV/JSON before it is loaded into SQLite
- Build scripts no longer needed at runtime

`.planning/` is excluded from the Docker image via `.dockerignore` and from cPanel deploys via `.cpanel.yml` exclude rules. It is committed to git so the build history is preserved.


title: Editing page content
content:
- All body copy is in `sections_json` column of `pages.sqlite`
- Edit `build/seed_databases.py` → `seed_sections()` → re-run → rebuild
- Never put body copy in PHP template files


title: What we never use
content:
- Node.js / npm packages on the website. Front-end JS uses zero packages
- WordPress for new builds (we migrate clients out of WordPress)
- CSS frameworks (Bootstrap, Tailwind, Bulma)
- JS frameworks (React, Vue, Angular, Svelte)
- jQuery, Lodash, Moment, axios, or any utility library
- CSS-in-JS, styled-components
- Build tools that require `node_modules` (webpack, vite, parcel, esbuild)
- Tracking pixels other than what the client explicitly requests
- A single monolithic database for unrelated content domains
- Hardcoded copy in PHP templates


title: Why this stack
content:
1. **Maintainability** — content is in the database. Changing copy never touches code. Templates and data stay separate.
2. **Performance** — PHP + SQLite on Alpine with no framework overhead serves in <50ms. No parse cost from JS bundles.
3. **Longevity** — no dependency rot. A site built today still runs in 10 years with no maintenance.
4. **Security** — no npm audit warnings, no supply-chain attack vectors, no transitive deps to patch.
5. **Auditability** — every line on the site is something we wrote and can read in plain text.
6. **Hosting** — a single Docker container fits the smallest VM tier any provider sells.


title: Source folder
content:
Lives in the dev tree under `concept-agent/projects/{domain}/site/`.
Contains everything needed to maintain and rebuild the site.

```
{domain}/site/
├── index.html                    # home page
├── about/index.html              # /about/
├── contact/index.html            # /contact/
├── reviews/index.html            # /reviews/
├── blog/index.html               # /blog/
├── locations/                    # location pages
│   ├── index.html                # /locations/
│   ├── _template.html            # template stamped with JSON
│   ├── buffalo.html              # generated, flat URL
│   ├── amherst.html
│   └── ...
├── services/
│   ├── index.html
│   ├── _template.html
│   ├── floor-refinishing.html
│   └── ...
├── components/
│   ├── header.html               # loaded via fetch() by components.js
│   └── footer.html
├── data/
│   ├── locations.json            # source data for build_locations.py
│   └── services.json             # source data for build_services.py
├── assets/
│   ├── css/
│   │   ├── main.css              # variables, reset, layout
│   │   └── components.css        # cards, hero, header, footer, nav, responsive
│   ├── js/
│   │   ├── main.js               # scroll animations, count-up, etc.
│   │   ├── components.js         # fetch + inject header/footer
│   │   └── form.js               # form validation + submit
│   ├── images/
│   ├── videos/                   # hero video files (.mp4 + .webm)
│   └── fonts/                    # only if not using Google Fonts CDN
├── build_locations.py            # JSON → flat .html stamping
├── build_services.py
└── README.md                     # project notes, content sources, status
```


title: Deployment folder
content:
Lives at `/home/sirdrez/arisingmedia-websites/{domain}/`.
Contains ONLY what's needed to run `docker compose up`.

```
{domain}/
├── index.html                    # all public website folders
├── about/                        # ↑
├── assets/                       # ↑
├── blog/                         # ↑
├── components/                   # ↑
├── contact/                      # ↑
├── locations/                    # ↑
├── reviews/                      # ↑
├── services/                     # ↑
├── api/                          # form-submit Python service (if used)
│   ├── server.py
│   ├── Dockerfile
│   ├── .env                      # gitignored — Resend key, etc.
│   └── .env.example
├── Dockerfile                    # nginx web container
├── nginx.conf
├── docker-compose.yml
├── .dockerignore
├── .gitignore
└── .planning/                    # everything not needed at runtime
    ├── build_locations.py        # build scripts moved here
    ├── data/                     # JSON sources moved here
    ├── README.md
    ├── DNS_*.txt                 # DNS notes
    └── review_*.png              # design review screenshots
```


title: What goes where
content:
**Source folder gets** every working file (build scripts, data JSON, screenshots,
notes, raw assets). This is the dev/maintenance copy. NOT what gets deployed.

**Deployment folder gets** ONLY the rendered website + the small API service.
Build scripts, JSON data, and notes go into `.planning/` to keep root clean and
prevent accidental web exposure.


title: Sync flow
content:
```
source/site/  →  rsync (excludes .planning, build_*.py, data/, etc.)  →  deployment/
                                                                        ↓
                                                              docker compose up -d --build
```

Sync command in `04-content-rules.md` and `08-deployment-docker.md`.


title: Pattern A: Flat HTML (default for Docker/nginx projects)
content:
nginx `try_files $uri $uri/ $uri.html =404` serves `/locations/buffalo` and
`/locations/buffalo.html`. Canonical form: `/locations/buffalo.html`.

Why flat:
- One file = one page, no `/index.html` confusion
- Easier sitemap generation
- `<a href>` links are unambiguous
- Crawl budget benefit — Google indexes one URL per page, not two


title: Pattern B: Directory-style (default for cPanel/Apache projects)
content:
Each page lives at `{slug}/index.html`. Apache auto-serves `index.html` when
visiting `/{slug}/`. Use this when deploying to cPanel shared hosting.

```
services/
├── carpet-cleaning/index.html     → /services/carpet-cleaning/
├── stairs/index.html              → /services/stairs/
commercial/
├── offices/index.html             → /commercial/offices/
└── vacation-rentals/index.html    → /commercial/vacation-rentals/
```


title: Lahrcarpetcleaning.com folder structure (directory-style, cPanel)
content:
```
lahrcarpetcleaning.com/
├── index.html
├── about/index.html
├── contact/index.html
├── reviews/index.html
├── service-area/index.html
├── locations/
│   ├── index.html
│   ├── waterloo-ny/index.html
│   ├── geneva-ny/index.html
│   └── ... (20 location pages)
├── services/
│   ├── carpet-cleaning/index.html
│   ├── stairs/index.html
│   ├── upholstery/index.html
│   ├── floors/index.html
│   ├── area-rugs/index.html
│   ├── add-ons/index.html
│   └── commercial/index.html
├── commercial/
│   ├── offices/index.html
│   ├── vacation-rentals/index.html
│   ├── hotels-inns/index.html
│   ├── retail-showrooms/index.html
│   └── property-management/index.html
├── assets/
│   ├── css/styles.css?v=N          ← always cache-bust on change
│   ├── js/
│   │   ├── main.js
│   │   └── components.js           ← injects nav+footer via innerHTML
│   ├── images/
│   │   ├── hero/                   ← hero-{slug}.webp, one per page
│   │   └── services/               ← {service}.webp card images
│   └── videos/hero/hero-reel.mp4
├── tools/                          ← NOT deployed to webroot
│   ├── convert-to-webp.py
│   ├── gen-images-flux.py
│   └── gen-hero-images.py
├── .cpanel.yml
├── robots.txt
├── sitemap.xml
├── 404.html
└── 500.html
```

All images are `.webp` — see `12-image-assets.md`.
cPanel deployment — see `08b-deployment-cpanel.md`.


title: Phase 1 — Capture source
content:
Before touching anything, capture the current site so nothing is lost.

1. **Database dump** — `wp db export ${domain}.sql --add-drop-table`
2. **Wp-content snapshot** — tar the entire `wp-content/` (themes, plugins, uploads)
3. **Crawl the live site** — use `wget --mirror --convert-links --adjust-extension --page-requisites --no-parent https://{domain}` to capture rendered HTML + all assets
4. **Inventory pages** — list every URL returning 200 (use the sitemap if it has one)
5. **Inventory forms** — note every Gravity Form / Contact Form 7 / etc. field-by-field
6. **Inventory dynamic features** — search, comments, members, anything truly dynamic

Save all of this in the project's `.planning/` folder.


title: Phase 2 — Decide what to keep
content:
Re-design pass. Most WP sites have:
- Bloated copy → cut by 30-50%
- Outdated/inflated metrics → remove or replace with real, verifiable data
- Stock photos → replace with real client photos when available
- Cluttered layouts → strip back to one clear CTA per section
- Plugin features the client never uses → drop entirely

Show the client a wireframe of the simplified structure before building anything.


title: Phase 3 — Information architecture
content:
Standard structure for a small business:

```
/                            home
/about/                      about / story / team
/services/                   services index
/services/{slug}.html        one detail page per service
/locations/                  locations index
/locations/{city}.html       one detail page per service area (SEO gold)
/reviews/                    customer reviews
/contact/                    contact + form
/blog/                       optional blog index
```

For each location and each service: one flat `.html` page generated from JSON +
template. See `03-build-pipeline.md`.


title: Phase 4 — Build
content:
1. Set up source folder per `01-project-structure.md`
2. Write `assets/css/main.css` (variables, reset, typography, layout)
3. Write `assets/css/components.css` (header, footer, hero, cards, forms)
4. Write `components/header.html` and `components/footer.html`
5. Write `assets/js/components.js` (fetch + inject header/footer)
6. Write `assets/js/main.js` (scroll animations, anything page-wide)
7. Build `index.html` first — this is the design system in working form
8. Generate location and service detail pages from JSON (see `03`)
9. Build remaining pages: about, contact, reviews, blog index


title: Phase 5 — Forms
content:
If the WP site had Gravity Forms or similar, build a vanilla replacement:
- HTML form in `contact/index.html` (and inline on service/location pages if needed)
- Client-side validation in `assets/js/form.js`
- POST to `/api/estimate` (or similar) handled by Python stdlib service
- Server-side validation, reCAPTCHA verification, send via Resend

See `07-form-handling.md`.


title: Phase 6 — SEO parity
content:
Before launch, every old URL must either:
- Have a matching new URL with the same or better content, OR
- 301-redirect to a relevant new URL

Build a redirect map from the old WP sitemap. Add to `nginx.conf`:

```nginx
location = /old-page-slug { return 301 /new-slug.html; }
location = /?p=123 { return 301 /about/; }
```

Per-page parity checklist:
- `<title>` matches or improves on the WP title
- `<meta name="description">` matches or improves
- `<link rel="canonical">` is set to the new URL
- Headings (h1, h2, h3) preserve the topical structure
- Internal links updated to new URLs
- Image alt text preserved or improved
- Schema.org JSON-LD added (`LocalBusiness`, `Service`, `BreadcrumbList`)


title: Phase 7 — Switch DNS / cutover
content:
1. Deploy the static site to a separate URL first (`new.{domain}`) for client review
2. Once approved, point production DNS to the new container
3. Keep the WP container running for 14 days as fallback
4. Submit new sitemap to Google Search Console
5. Use Search Console URL inspection on 5-10 key pages to confirm indexing


title: Phase 8 — Post-launch
content:
- Monitor Search Console for crawl errors / 404s, fix in nginx as redirects
- Monitor form submissions — first real lead through the new form is the
  ultimate "it works" check
- Decommission WP only after 30 days of clean operation


title: What NOT to do
content:
- Do not run a "headless WordPress" or "WordPress as API" — that defeats the
  whole point. Static means static.
- Do not use a static-site-generator tool (Hugo, 11ty, Jekyll, Astro, Next.js
  static export). We hand-write HTML and use small Python build scripts only
  where data is repeated.
- Do not migrate the database. Content gets re-written cleaner during migration.


title: When to use a build script
content:
Use it when there are 4+ pages with identical structure differing only in
content. For example: 6 location pages where only the city name and
city-specific copy differs.

For one-off pages (home, about, contact, services index), hand-write the HTML
directly. Build scripts are for repetition, not for everything.


title: Pattern
content:
Three files per template family:

1. **`data/{thing}.json`** — array of objects, one per page
2. **`{thing}/_template.html`** — HTML with `{{placeholder}}` markers
3. **`build_{thing}.py`** — stdlib Python, stamps template with data


title: Example: locations.json
content:
```json
[
  {
    "slug": "buffalo",
    "city": "Buffalo",
    "state": "NY",
    "title": "Hardwood Floor Refinishing in Buffalo, NY | Floor It",
    "meta_description": "Professional hardwood floor refinishing...",
    "canonical": "https://floorithardwoodfloors.com/locations/buffalo.html",
    "hero_h1": "Hardwood Floor Refinishing in Buffalo, NY",
    "hero_lead": "Western New York's most experienced...",
    "overview_h2": "Buffalo's Trusted Floor Refinishing Specialists",
    "overview_body_1": "...",
    "overview_body_2": "...",
    "faqs": [
      { "q": "...", "a": "..." }
    ]
  }
]
```


title: Example: _template.html
content:
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <title>{{title}}</title>
  <meta name="description" content="{{meta_description}}">
  <link rel="canonical" href="{{canonical}}">
  ...
</head>
<body>
  <h1>{{hero_h1}}</h1>
  <p>{{hero_lead}}</p>
  ...
</body>
</html>
```


title: Example: build_locations.py (skeleton)
content:
```python
"""Build flat .html location pages from data/locations.json + locations/_template.html."""
import json, sys
from pathlib import Path

SITE_ROOT     = Path(__file__).parent
DATA_FILE     = SITE_ROOT / "data" / "locations.json"
TEMPLATE_FILE = SITE_ROOT / "locations" / "_template.html"
OUT_DIR       = SITE_ROOT / "locations"

def render(template: str, item: dict) -> str:
    out = template
    for key, value in item.items():
        if isinstance(value, (str, int, float)):
            out = out.replace("{{" + key + "}}", str(value))
    # Custom rendering for nested arrays (e.g. faqs)
    # ... handle item['faqs'] etc.
    return out

def main():
    data = json.loads(DATA_FILE.read_text(encoding="utf-8"))
    template = TEMPLATE_FILE.read_text(encoding="utf-8")
    print(f"Building {len(data)} location pages...")
    for item in data:
        rendered = render(template, item)
        outfile = OUT_DIR / f"{item['slug']}.html"
        outfile.write_text(rendered, encoding="utf-8")
        print(f"  Built: {outfile.relative_to(SITE_ROOT)}")
    print(f"Done. {len(data)} pages written.")

if __name__ == "__main__":
    main()
```


title: Rules
content:
1. **Source of truth is JSON, not HTML.** When content needs to change, edit the
   JSON and re-run the build script. Never hand-edit a generated `.html` file —
   the next build will overwrite your changes.

2. **Generated files land in the same folder as their template.** Do not nest
   into a subfolder. The template file is always named `_template.html` (leading
   underscore so it sorts above the generated pages).

3. **Build script lives in the SOURCE root**, not in deployment. After running
   the build, sync the rendered `.html` files (not the script, not the JSON) to
   deployment.

4. **Verify zero unreplaced placeholders** after every build:
   ```bash
   grep -rn "{{" {thing}/*.html  # should return nothing
   ```

5. **Build is idempotent.** Running it twice produces identical files.


title: Stamping rules — escaping
content:
When a JSON value gets stamped into an HTML attribute or `<title>`, special
characters can break the page. Use these rules:

- Plain text in `<p>` or `<h1>`: ampersand-encode (`&` → `&amp;`)
- `<title>` content: ampersand-encode + strip line breaks
- `<meta>` content attribute: encode `&`, `"`, and remove line breaks
- `href` URL attribute: never put user input here, but if needed, urlencode

For our typical use case (controlled content authored by us), the simple
`str.replace("{{key}}", value)` is sufficient because we don't have hostile
input. Just don't put angle brackets or quotes in the JSON values.


title: Re-running the build
content:
```bash
cd {project}/site
python3 build_locations.py
python3 build_services.py
```

After build, sync the rendered files to deployment (see `08-deployment-docker.md`).


title: No em-dashes or en-dashes — anywhere
content:
Do not use `—` (em-dash) or `–` (en-dash) on any page. They look like AI-generated
copy and trip spam filters in transactional email. Replace with:
- A period: `solution — fast` → `solution. Fast`
- A comma: `Buffalo — including older homes` → `Buffalo, including older homes`
- A colon: `services available: refinishing, restoration` (instead of `— refinishing`)
- The word "to": `Mon-Sat: 8 AM-5 PM` → `Monday to Saturday: 8 AM to 5 PM`
- The word "and": `clear timelines — professional workmanship` → `clear timelines and professional workmanship`

This applies to:
- Page body copy
- `<title>` tags (use `|` pipe instead)
- `<meta name="description">`
- Image `alt` text
- Form option labels (`Good — Minor scratches` → `Good: Minor scratches`)
- Email subject lines and body
- Schema.org JSON-LD content

Run a final check before declaring done:
```bash
grep -rn '—\|–\|&mdash;\|&ndash;' site/ --include="*.html" --include="*.json"
```
Result must be zero matches.


title: No fake numbers, ratings, or claims
content:
Never invent metrics. The site can only state numbers that the client can prove.

Forbidden without proof:
- "100+ stain colors" (do they actually have 100? if not, say "multiple stain options")
- "HEPA dustless sanding" (does the equipment actually have HEPA filtration? if not, say "dustless sanding")
- "99% satisfaction rate" (where does this number come from?)
- "Award-winning" (which award?)
- "#1 in [city]" (according to whom?)
- Specific years of experience without confirming

If the client has Google reviews showing 4.9 stars, that's a real number, use it.
If the client has 30 years in business, use it. If neither is documented, omit.


title: No filler / AI-tells
content:
Watch for and remove:
- "In today's fast-paced world..."
- "Look no further!"
- "Whether you're X or Y..."
- "Don't hesitate to contact us"
- "We pride ourselves on..."
- "State-of-the-art technology"
- "Cutting-edge solutions"
- Every sentence ending in an exclamation point

These are LLM defaults. Real businesses don't write like this.


title: Tone
content:
Match what the client's actual customers would say. For a hardwood-floor
business in Buffalo:
- Write at 7th-8th grade reading level
- Lead with the customer's problem (worn floors, water damage)
- One clear CTA per section (call, request estimate, view services)
- Active voice ("We refinish floors") not passive ("Floors are refinished")
- Short paragraphs (2-4 sentences max)
- No marketing jargon ("synergize", "leverage", "best-in-class")


title: Specificity over superlative
content:
- "We restored a 90-year-old oak floor in Elmwood Village last month" is better than "We're experts at historic floor restoration"
- "Most projects take 7 to 10 business days" is better than "We work fast"
- "The technician took moisture readings before sanding" is better than "Professional preparation"


title: Hours / phone / address formatting
content:
- Phone: `(716) 602-1429` or `tel:+17166021429`
- Hours: `Monday to Saturday: 8:00 AM to 5:00 PM` (no dashes)
- Address: full mailing address with state and zip
- All three appear in the footer on every page, in identical format
- All three appear in `LocalBusiness` schema JSON-LD


title: Headings
content:
- One `<h1>` per page (the page's main topic)
- `<h2>` for major sections
- `<h3>` for cards/items within a section
- Never skip levels (no `<h1>` then `<h3>`)
- Headings describe the section content, not "Welcome" or "Section 2"


title: Links
content:
- Internal links: relative path with `.html` (`/services/floor-refinishing.html`)
- External links: include `target="_blank" rel="noopener"`
- Phone links: `tel:+1{10digits}` (no spaces)
- Email links: `mailto:address`
- Map links: open in new tab


title: Images
content:
- Every `<img>` needs descriptive `alt` text or `alt=""` if purely decorative
- Use `loading="lazy"` on every image except the hero
- Use `.webp` for photos, `.svg` for icons/logos
- Provide `width` and `height` attributes to prevent layout shift


title: Breakpoints
content:
Standard set, used in every project:

```css
/* Default: mobile-first (no media query) — styles for 320px+ */

@media (max-width: 360px) { /* iPhone SE portrait — tightenings */ }
@media (max-width: 480px) { /* small phones */ }
@media (max-width: 600px) { /* phones (gap reductions, font tightening) */ }
@media (max-width: 768px) { /* tablets — main mobile breakpoint */ }
@media (max-width: 900px) { /* small laptops, tablet landscape */ }
@media (max-width: 1023px) { /* IMPORTANT: switch to mobile menu here */ }
@media (max-width: 1024px) { /* sub-desktop — tighten spacing */ }
```


title: Header / nav rule
content:
Switch to the slide-out mobile menu at `max-width: 1023px`, NOT 768px.

Reason: a typical header has logo + 5-7 nav items + phone number + CTA button.
That doesn't fit cleanly until the viewport is at least 1024px wide. Below
that, the desktop nav cramps and the CTA button overflows off the right edge.

```css
@media (max-width: 1023px) {
  .header-nav { display: none; }
  .header-phone { display: none; }
  .header-menu-btn { display: flex; }
}
```


title: Inline-style grid override
content:
Section layouts often use inline `style="display:grid;grid-template-columns:1fr 1fr"`
for image+text splits. Inline styles override CSS — but `!important` inside a
media query DOES override inline styles.

Always include this block at the END of `components.css`:

```css
@media (max-width: 900px) {
  [style*="grid-template-columns:1fr 1fr"],
  [style*="grid-template-columns: 1fr 1fr"],
  [style*="grid-template-columns:repeat(2,1fr)"],
  [style*="grid-template-columns: repeat(2,1fr)"],
  [style*="grid-template-columns:repeat(2, 1fr)"],
  [style*="grid-template-columns: repeat(2, 1fr)"] {
    grid-template-columns: 1fr !important;
    gap: 2rem !important;
  }

  /* Order overrides — flip image/content columns reset on mobile */
  [style*="order:1"], [style*="order: 1"],
  [style*="order:2"], [style*="order: 2"] {
    order: 0 !important;
  }
}
```


title: Page-overflow protection
content:
Mobile slide-out nav panels and other off-screen positioned elements can
expand `document.documentElement.scrollWidth` past the viewport, creating an
unwanted horizontal scroll. Always include:

```css
html, body {
  overflow-x: clip;
  max-width: 100%;
}
```


title: Form field overflow
content:
`<input>`, `<select>`, and `<textarea>` have intrinsic widths that propagate up
through flex/grid containers via `min-width: auto` defaults. On narrow screens
this pushes the layout wider than the viewport.

```css
@media (max-width: 900px) {
  .contact-layout,
  .contact-form-wrap,
  .contact-info-list,
  .form-grid,
  .form-grid--2,
  .form-field { min-width: 0; }
  .form-field input,
  .form-field textarea,
  .form-field select {
    min-width: 0;
    max-width: 100%;
    box-sizing: border-box;
  }
}
```


title: Verification — before declaring done
content:
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.


title: Animation on mobile
content:
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.


title: Touch targets
content:
- 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


title: Test devices
content:
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


title: Required `<head>` tags (every page)
content:
```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>
```


title: OG image
content:
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.


title: Home page — LocalBusiness
content:
```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"
  }
}
```


title: Service detail pages — Service
content:
```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}"
}
```


title: Location pages — LocalBusiness with areaServed override
content:
Same as the home `LocalBusiness` schema but override `areaServed` to the
specific city, and include a different `@id` per page.


title: Every page — BreadcrumbList
content:
```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" }
  ]
}
```


title: FAQ pages or sections — FAQPage
content:
```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..." }
  }]
}
```


title: robots.txt
content:
Every site needs one at `/robots.txt`:

```
User-agent: *
Allow: /
Disallow: /api/

Sitemap: https://{domain}/sitemap.xml
```


title: sitemap.xml
content:
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: Title and description rules
content:
- **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, `—` only in title brand block
- Always include `<meta name="keywords">` — 5-10 comma-separated terms, city + service variants


title: Title/meta examples (lahrcarpetcleaning.com reference)
content:
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/">
```


title: Audit before launch
content:
```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.

---


title: llms.txt — AI Crawler Documentation
content:
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.}


title: Not for external use
content:
- /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.

---


title: JSON-LD Schema — By Site Type
content:
Schema goes in `<script type="application/ld+json">` in every page `<head>`. Choose the type that matches the site.


title: LocalBusiness (service businesses — carpet cleaning, flooring, consulting, healthcare)
content:
```json
{
  "@context": "https://schema.org",
  "@type": "LocalBusiness",
  "name": "{Business Name}",
  "url": "https://{domain}",
  "telephone": "{phone}",
  "email": "{email}",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "{street}",
    "addressLocality": "{city}",
    "addressRegion": "{state}",
    "postalCode": "{zip}",
    "addressCountry": "US"
  },
  "description": "{Business description}",
  "priceRange": "$$",
  "areaServed": "{City, State}",
  "openingHours": "Mo-Fr 09:00-17:00"
}
```


title: WebApplication (SaaS tools, online converters, web apps)
content:
```json
{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "name": "{App Name}",
  "url": "https://{domain}",
  "description": "{What the tool does}",
  "applicationCategory": "UtilitiesApplication",
  "operatingSystem": "Any",
  "offers": {
    "@type": "Offer",
    "price": "0",
    "priceCurrency": "USD"
  },
  "provider": {
    "@type": "Organization",
    "name": "Arising Media",
    "url": "https://arisingmedia.us"
  }
}
```


title: ProfessionalService (consultants, agencies, coaching)
content:
```json
{
  "@context": "https://schema.org",
  "@type": "ProfessionalService",
  "name": "{Name}",
  "url": "https://{domain}",
  "description": "{Description}",
  "serviceType": "{Service category}",
  "provider": {
    "@type": "Person",
    "name": "{Person name}"
  }
}
```


title: FAQPage (add to FAQ pages only, in addition to the base schema)
content:
```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "{Question text?}",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "{Answer text}"
      }
    }
  ]
}
```


title: Architecture
content:
```
Browser  →  POST /api/estimate          (vanilla JS fetch in form.js)
            ↓
nginx    →  proxies /api/ to api:3001   (strips /api/ prefix)
            ↓
Python service (server.py, stdlib only)
  - Validates fields server-side
  - Verifies reCAPTCHA v3 with Google
  - Sends via Resend HTTPS API
  - Returns {ok: true} or {error: ...}
```


title: Front-end (vanilla JS)
content:
`assets/js/form.js`:

- Real-time validation (blur events)
- Phone formatting `(###) ###-####`
- Email regex check
- Required-field check
- Async submit to `/api/estimate` with JSON body
- Disable submit button + show "Sending..." during request
- Show success/error message in `.form-status` span
- Reset form on success
- reCAPTCHA v3 token fetched before submit and included in body


title: Back-end (Python stdlib)
content:
`api/server.py`:

```python
#!/usr/bin/env python3
import hashlib, http.server, json, os, re, socketserver, time
import urllib.parse, urllib.request

PORT             = int(os.environ.get("PORT", "3001"))
RESEND_API_KEY   = os.environ.get("RESEND_API_KEY", "")
RECAPTCHA_SECRET = os.environ.get("RECAPTCHA_SECRET", "")
TO_EMAIL         = os.environ.get("TO_EMAIL", "")
FROM_EMAIL       = os.environ.get("FROM_EMAIL", "")
RECAPTCHA_MIN    = float(os.environ.get("RECAPTCHA_MIN", "0.5"))

PHONE_RE = re.compile(r"^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$")
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")

# Rate limit: 5 requests / IP / 15 minutes
RATE_MAP = {}
RATE_WINDOW = 15 * 60
RATE_MAX = 5

def sanitize(s):
    if not isinstance(s, str): return ""
    return s.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;").strip()[:2000]

def validate_fields(body):
    errors = []
    if not body.get("name") or len((body["name"]).strip()) < 2: errors.append("name")
    if not EMAIL_RE.match((body.get("email") or "").strip()): errors.append("email")
    if not PHONE_RE.match((body.get("phone") or "").replace(" ", "")): errors.append("phone")
    # ... etc
    return errors

def verify_recaptcha(token):
    if not RECAPTCHA_SECRET or not token: return 0.0
    data = urllib.parse.urlencode({"secret": RECAPTCHA_SECRET, "response": token}).encode()
    req = urllib.request.Request("https://www.google.com/recaptcha/api/siteverify", data=data)
    try:
        with urllib.request.urlopen(req, timeout=8) as resp:
            return float(json.loads(resp.read()).get("score", 0))
    except Exception:
        return 0.0

def send_via_resend(fields):
    safe = {k: sanitize(fields.get(k,"")) for k in ["name","email","phone","address","city","zip","service","condition","message"]}
    html = f"""<!DOCTYPE html>...{safe['name']}..."""
    text = f"New estimate request\n\nName: {safe['name']}\n..."
    payload = json.dumps({
        "from": FROM_EMAIL,
        "to": [TO_EMAIL],
        "reply_to": fields.get("email","").strip(),
        "subject": f"New estimate request: {safe['name']} ({safe['city']})",
        "html": html, "text": text,
    }).encode("utf-8")
    idem = hashlib.sha256(payload).hexdigest()[:64]
    req = urllib.request.Request("https://api.resend.com/emails", data=payload, headers={
        "Authorization": f"Bearer {RESEND_API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
        "User-Agent": "{Brand}-Estimate-Form/1.0",  # IMPORTANT: Cloudflare blocks default Python UA
    })
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            if resp.status >= 300: raise RuntimeError(f"Resend {resp.status}: {resp.read().decode('utf-8','ignore')}")
    except urllib.error.HTTPError as e:
        raise RuntimeError(f"Resend {e.code}: {e.read().decode('utf-8','ignore')}") from None
```

Full reference implementation: see `floorithardwoodfloors.com/api/server.py`.


title: Critical: User-Agent header
content:
When calling the Resend API from Python, you MUST set a non-default User-Agent.
Cloudflare (which fronts Resend) blocks Python's default `Python-urllib/3.x`
with HTTP 403 / Cloudflare error code 1010.

```python
"User-Agent": "{ProjectName}-Form/1.0"
```


title: Idempotency
content:
Every Resend request includes an `Idempotency-Key` header set to the SHA-256
of the payload (truncated to 64 chars). Identical payloads within 24 hours
are deduplicated by Resend automatically. This prevents:
- Double-clicks creating two leads
- Browser retries after a network blip
- Honest user submitting twice


title: Security checklist
content:
- API key in `.env` file, NOT in source control. `.gitignore` it.
- API key NEVER reaches the browser bundle (only the server has it)
- `.env` file lives in `api/`, NOT in the nginx web root
- Server-side validation on EVERY field — never trust client
- HTML-escape every field rendered into the email body to prevent injection
- Rate limit per IP (5 / 15 min default)
- 16 KB body cap — reject anything larger
- 10-second upstream timeout — don't hold connections open
- CORS locked to the production domain only (`Access-Control-Allow-Origin: https://{domain}`)
- reCAPTCHA v3 with score threshold (default 0.5) once secret is configured


title: Environment variables
content:
`api/.env`:
```
RESEND_API_KEY=re_xxxxxxxxxxxx
RECAPTCHA_SECRET=6Ldq...
TO_EMAIL=leads@{domain}
FROM_EMAIL=Brand Name <webleads@{domain}>
RECAPTCHA_MIN=0.5
PORT=3001
```

`api/.env.example` (committed) is the same file with placeholder values.


title: reCAPTCHA setup
content:
1. Create site at https://www.google.com/recaptcha/admin
2. Type: **reCAPTCHA v3** (not v2)
3. Add your domain
4. Copy the **site key** into `assets/js/form.js`:
   ```js
   const RECAPTCHA_SITE_KEY = '6Ldq...';
   ```
5. Add the script tag to pages with the form:
   ```html
   <script src="https://www.google.com/recaptcha/api.js?render=6Ldq..."></script>
   ```
6. Copy the **secret key** into `api/.env` as `RECAPTCHA_SECRET`


title: Deliverability checklist
content:
When emails are landing in spam:
1. Verify Resend domain is fully green (SPF + DKIM + DMARC) — see `09-domain-email-dns.md`
2. From name set, not bare email: `Brand Name <webleads@{domain}>`
3. Both `html` and `text` parts in every Resend payload (no HTML-only)
4. Subject line is descriptive, no em-dash, no spam-trigger words
5. Recipient marks first 2-3 emails as "Not Spam" in Gmail to train the filter


title: Testing
content:
```bash
# Validation rejection (expect 422)
curl -X POST http://localhost:8096/api/estimate \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"bad"}'

# Full valid submission (expect 200, real email sent)
curl -X POST http://localhost:8096/api/estimate \
  -H "Content-Type: application/json" \
  -d '{"name":"Test","email":"test@example.com","phone":"(716) 555-1234","address":"100 Test St","city":"Buffalo","zip":"14201","service":"refinishing","message":"Test","token":""}'
```

The first real test email confirms end-to-end works.

---


title: Alternative Stack: nginx + php-fpm (single container)
content:
Some projects use a single container running nginx + php-fpm (via supervisord)
instead of the two-container Python setup. The form handler is a PHP file
instead of `server.py`. This is the pattern when the project was built with PHP
or the client host expects PHP.

```
Browser  →  POST /api/contact
            ↓
nginx    →  rewrites /api/contact → /api/contact.php (rate-limited)
            ↓
php-fpm  →  /var/www/html/api/contact.php
  - Validates fields server-side
  - Verifies reCAPTCHA v3
  - Sends via Resend HTTPS API (curl or file_get_contents)
  - Returns JSON {ok: true} or {error: ...}
```


title: nginx rule for PHP form endpoint
content:
```nginx
location = /api/contact {
    limit_req zone=contact_limit burst=3 nodelay;
    limit_req_status 429;
    rewrite ^ /api/contact.php last;
}

location ~ \.php$ {
    try_files $uri =404;
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
}
```


title: CRITICAL: php-fpm must pass env vars to PHP
content:
By default, php-fpm **strips all environment variables** before spawning worker
processes. PHP scripts then get empty strings from `getenv()`. Fix:

`/etc/php-fpm.d/www.conf` or the pool override file:
```ini
[www]
clear_env = no
```

Without this, `getenv('RESEND_API_KEY')` returns `""` and emails silently
fall back to the default or skip sending with no error in the form response.


title: env var naming must match what PHP reads
content:
The `.env` file variable names must exactly match what the PHP script calls
with `getenv()`. Common mismatch pattern:

| `.env` key (wrong)  | What PHP reads        | Effect               |
|---------------------|-----------------------|----------------------|
| `MEGHAN_INBOX_TO`   | `SEND_TO_EMAIL`       | Falls back to default hardcoded value |
| `FROM_ADDRESS`      | `SEND_FROM_EMAIL`     | Falls back to default |

Always read the PHP file first, find every `getenv()` call, and name the
`.env` keys accordingly.


title: .env placement for single-container stack
content:
When there is no separate api container, the `.env` lives in the project
root alongside `docker-compose.yml`. The compose file passes it as
`env_file: .env` or explicit `environment:` entries. Confirm with:

```bash
docker exec {container} env | grep RESEND
```


title: Path A — Docker + nginx (VPS)
content:
Every project deploys as a Docker Compose stack with two containers:
nginx (web) and Python (api). Sites without a form omit the api service.


title: docker-compose.yml
content:
```yaml
services:
  web:
    image: {domain}-static
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "{port}:80"
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped

  api:
    image: {domain}-api
    build:
      context: ./api
      dockerfile: Dockerfile
    env_file: ./api/.env
    expose:
      - "3001"
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3001/health',timeout=3).status==200 else 1)"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped
```

Port assignments are unique per project. Track in
`/home/sirdrez/arisingmedia-websites/PORTS.md` so no two projects collide.


title: Dockerfile (nginx web container)
content:
CRITICAL — the Dockerfile must explicitly list which folders to copy. Never use
`COPY . /usr/share/nginx/html/` because that copies `.env`, `Dockerfile`,
build scripts, etc. into the web root where they become URL-accessible.

```dockerfile
FROM nginx:alpine

# nginx config — server-only, never served as a static file
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Public website only — explicit list, no wildcards
COPY index.html         /usr/share/nginx/html/
COPY assets             /usr/share/nginx/html/assets/
COPY components         /usr/share/nginx/html/components/
COPY about              /usr/share/nginx/html/about/
COPY blog               /usr/share/nginx/html/blog/
COPY contact            /usr/share/nginx/html/contact/
COPY locations          /usr/share/nginx/html/locations/
COPY reviews            /usr/share/nginx/html/reviews/
COPY services           /usr/share/nginx/html/services/

EXPOSE 80
```


title: Dockerfile (api Python container)
content:
```dockerfile
FROM python:3.13-alpine
WORKDIR /app
COPY server.py .
EXPOSE 3001
CMD ["python3", "-u", "server.py"]
```

No pip, no requirements.txt, no node_modules. Python stdlib only.


title: nginx.conf
content:
```nginx
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Defense in depth — deny dotfiles, configs, scripts, source files
    location ~ /\. {
        deny all;
        return 404;
    }
    location ~* \.(env|env\.example|conf|yml|yaml|py|pyc|md|txt|sh|sql|log|bak|old|swp|dockerfile)$ {
        deny all;
        return 404;
    }
    location = /Dockerfile {
        deny all;
        return 404;
    }

    # API proxy — strip /api/ prefix, forward to Python service
    location /api/ {
        proxy_pass         http://api:3001/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 10s;
        proxy_connect_timeout 5s;
    }

    # Flat HTML routing — /locations/buffalo serves /locations/buffalo.html
    location / {
        try_files $uri $uri/ $uri.html =404;
    }

    # Cache static assets aggressively
    location ~* \.(jpg|jpeg|png|webp|svg|ico|css|js|woff2?|mp4|webm)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    gzip on;
    gzip_types text/html text/css application/javascript image/svg+xml;
    gzip_min_length 1024;

    error_page 404 /index.html;
}
```


title: .dockerignore
content:
Keeps sensitive files out of the build context (defense layer 1):

```
.git
.gitignore
.dockerignore
api
build_*.py
__pycache__
*.pyc
*.md
*.txt
review_*.png
docker-compose.yml
.DS_Store
.planning
```

Note: `api` is excluded from the WEB build context. The API container has
its own Dockerfile with its own context (`./api`).


title: .gitignore
content:
```
api/.env
api/__pycache__/
__pycache__/
*.pyc
*.log
.DS_Store
```

The `api/.env` file is NEVER committed.


title: Sync from source to deployment
content:
After every change to source HTML/CSS/JS/assets:

```bash
SITE="/path/to/concept-agent/projects/{domain}/site"
DEPLOY="/home/sirdrez/arisingmedia-websites/{domain}"

rsync -a \
  --exclude=.git --exclude=.planning --exclude=api \
  --exclude=Dockerfile --exclude=nginx.conf --exclude=docker-compose.yml \
  --exclude=.dockerignore --exclude=.gitignore \
  --exclude='build_*.py' --exclude=__pycache__ --exclude=data \
  --exclude='*.md' --exclude='*.txt' --exclude='review_*.png' \
  "$SITE/" "$DEPLOY/"

cd "$DEPLOY"
docker compose up -d --build web
```

The `--exclude` list mirrors `.dockerignore` — keeps deployment-only files
(Dockerfile, nginx.conf) intact and stops dev/source files from polluting
the deployment folder.


title: Verify after deploy
content:
Every deploy MUST be audited with `tools/verify-protection.sh` before being
considered live. The script probes a fixed list of sensitive paths
(`Dockerfile`, `.env`, `nginx.conf`, `.planning/`, `__pycache__/`, build
scripts, `.git/`, etc.) and fails if any returns 200.

```bash
~/arisingmedia-websites/am-webdesign-sops/tools/verify-protection.sh \
  http://localhost:{port}
```

Exit codes:
- `0` PASS — every sensitive path 404, every required path reachable.
- `0` PASS (with warnings) — protection clean but `/robots.txt` or
  `/sitemap.xml` missing (content gap, not a leak).
- `1` FAIL — at least one sensitive path returned 200, or `/` is unreachable.

Run it manually after every `docker compose up -d --build`. Wire it into CI
once the site has a remote pipeline. Treat a FAIL as a deploy rollback.

For ad-hoc spot checks beyond the script:

```bash
curl -s -o /dev/null -w "site:    %{http_code}\n" http://localhost:{port}/
curl -s -o /dev/null -w "css:     %{http_code}\n" http://localhost:{port}/assets/css/main.css
curl -s -o /dev/null -w "api:     %{http_code}\n" http://localhost:{port}/api/health
```

All public paths return 200. All sensitive paths return 404.

---


title: Project Folder Rename Procedure
content:
WHY: Docker Compose derives its project name from the folder the
`docker-compose.yml` lives in. Renaming the folder changes the compose project
name, which orphans any running containers under the old name. Running
`docker compose up -d` from the new path will fail with:

```
Error: The container name "/vibrantyou-web" is already in use
```

The fix is to explicitly remove the old container before bringing up the new
compose project:

```bash
# Stop and remove the old container by its known name
docker stop {container-name}
docker rm {container-name}

# Now bring up from the renamed folder — clean start
docker compose -f /path/to/renamed-folder/docker-compose.yml up -d
```

Always confirm the env vars loaded correctly after restart:

```bash
docker exec {container-name} env | grep RESEND
```

---


title: .env and .gitignore at Project Root
content:
Every project must have both files created at setup, before the first commit:

`.gitignore` (minimum):
```
.env
*.env
```

`.env` (root, gitignored):
```
RESEND_API_KEY=re_xxxx
SEND_TO_EMAIL=owner@example.com
SEND_FROM_EMAIL=noreply@{domain}
```

For single-container (nginx + php-fpm) stacks, `.env` and `.gitignore`
live in the project root, not in an `api/` subdirectory.


title: Project port allocation
content:
Track in a single shared file. New projects pick the next available number.

```
8092  andrecobham.com
8094  backforge.nl
8095  ...
8096  floorithardwoodfloors.com
8097  next available
```


title: Putting it behind a real domain
content:
In production (a VM with a public IP), add a reverse proxy in front of the
container — typically Caddy or another nginx instance — that handles TLS
termination via Let's Encrypt and routes by `Host` header to the right
container port.

`Caddyfile` example:
```
{domain}.com {
    reverse_proxy localhost:{port}
}
```

Caddy auto-issues and renews TLS certs.

---


title: Key rule: repo path ≠ webroot
content:
cPanel Git requires an EMPTY directory as the repository path.
The webroot (`public_html/{domain}/`) is never the repo path — cPanel
rejects it if it contains any files.

```
Repo path (empty):   /home/{username}/repositories/{domain}/
Webroot (served):    /home/{username}/public_html/{domain}/
```


title: .cpanel.yml (ship in every project)
content:
```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/{username}/public_html/{domain}/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```

Adjust folder list to match the project. Never copy: `tools/`, `*.py`,
`*.md`, `.git/`, `Dockerfile`, `docker-compose.yml`, `nginx.conf`.


title: .htaccess (ship in every project)
content:
Apache/cPanel needs `.htaccess` for clean URLs and error pages.
Place at project root — cPanel copies it to webroot via `.cpanel.yml`:

```apache
Options -Indexes
RewriteEngine On

# Directory-style URLs: /services/carpet-cleaning/ → index.html inside that folder
# Apache handles this automatically with DirectoryIndex — no extra rules needed

# Deny sensitive files
<FilesMatch "\.(py|yml|yaml|md|log|sh|env|conf|dockerfile)$">
    Order allow,deny
    Deny from all
</FilesMatch>

ErrorDocument 404 /404.html
ErrorDocument 500 /500.html
```


title: Deploy flow (cPanel)
content:
1. Push to GitHub remote
2. cPanel → Git Version Control → Manage → Pull or Deploy
3. cPanel runs `.cpanel.yml` tasks, copying files to webroot
4. Apache serves from webroot — no restart needed


title: Cache busting on cPanel
content:
Apache does not auto-invalidate cached assets. Bump `?v=N` on CSS/JS in
all HTML files after every asset change:

```html
<link rel="stylesheet" href="/assets/css/styles.css?v=6">
<script src="/assets/js/main.js?v=3"></script>
```

Increment by 1 on every change. Apply across ALL HTML pages.


title: Verify after cPanel deploy
content:
```bash
curl -s -o /dev/null -w "home: %{http_code}\n" https://{domain}/
curl -s -o /dev/null -w "css:  %{http_code}\n" https://{domain}/assets/css/styles.css
curl -s -o /dev/null -w "404:  %{http_code}\n" https://{domain}/page-that-does-not-exist
```

---


title: Universal project checklist (both paths)
content:
Every project must include ALL of these before first deploy:

```
Dockerfile           ✓  Docker/VPS
docker-compose.yml   ✓  Docker/VPS
nginx.conf           ✓  Docker/VPS
.htaccess            ✓  cPanel/Apache
.cpanel.yml          ✓  cPanel Git
.dockerignore        ✓  Docker build security
.gitignore           ✓  keeps .env and secrets out of git
robots.txt           ✓  both paths
sitemap.xml          ✓  both paths
404.html             ✓  both paths
```

Lahrcarpetcleaning.com is the reference implementation for both paths.


title: Key rule: repo path ≠ webroot
content:
cPanel Git requires an EMPTY directory as the repository path. The webroot
(`public_html/{domain}/`) is never the repo path — cPanel rejects it if it
already contains files.

```
Repo path (empty dir):    /home/{username}/repositories/{domain}/
Deploy target (webroot):  /home/{username}/public_html/{domain}/
```


title: Setting up the repo in cPanel
content:
1. cPanel → Git Version Control → Create Repository
2. Repository Path: `/home/{username}/repositories/{domain}/` (must be empty)
3. Clone URL: your Git remote (GitHub, Bitbucket, etc.)
4. cPanel clones into the repo path — never into the webroot


title: .cpanel.yml
content:
This file lives in the repo root and tells cPanel what to copy to the webroot
on every push/deploy. All paths are relative to the repo root.

```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/{username}/public_html/{domain}/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```

Add or remove folder cp lines to match the project's actual directory structure.
Do NOT copy: `tools/`, `*.py`, `*.md`, `.git/`, `docker-compose.yml`, `Dockerfile`.


title: Lahrcarpetcleaning.com reference
content:
```yaml
---
deployment:
  tasks:
    - export DEPLOYPATH=/home/dev1communitypro/public_html/lahrcarpetcleaning.dev1.communityproud.com/
    - /bin/cp -r assets $DEPLOYPATH
    - /bin/cp -r about $DEPLOYPATH
    - /bin/cp -r commercial $DEPLOYPATH
    - /bin/cp -r contact $DEPLOYPATH
    - /bin/cp -r locations $DEPLOYPATH
    - /bin/cp -r reviews $DEPLOYPATH
    - /bin/cp -r service-area $DEPLOYPATH
    - /bin/cp -r services $DEPLOYPATH
    - /bin/cp index.html $DEPLOYPATH
    - /bin/cp 404.html $DEPLOYPATH
    - /bin/cp robots.txt $DEPLOYPATH
    - /bin/cp sitemap.xml $DEPLOYPATH
```


title: Deploying after a push
content:
1. Push to the connected remote (GitHub)
2. cPanel → Git Version Control → Manage → Pull or Deploy
3. cPanel runs the `.cpanel.yml` tasks, copying files to webroot
4. Apache serves from webroot automatically — no nginx, no Docker


title: Apache vs nginx
content:
cPanel hosts use Apache (not nginx). There is no nginx.conf to manage.
URL routing is handled by `.htaccess`:

```apache
Options -Indexes
RewriteEngine On

# Remove .html extension
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([^.]+)$ $1.html [NC,L]

# Redirect trailing slash to clean URL
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [R=301,L]

ErrorDocument 404 /404.html
```

For directory-style URLs (`/services/carpet-cleaning/`), the above is not
needed — Apache serves `index.html` inside each folder automatically when
`DirectoryIndex index.html` is set (the cPanel default).


title: What NOT to copy to webroot
content:
Never copy these to `DEPLOYPATH`:
- `tools/` — Python generation scripts, sensitive
- `*.py` — build/generation scripts
- `*.md` — documentation
- `docker-compose.yml`, `Dockerfile`, `nginx.conf`
- `.git/`, `.gitignore`, `.cpanel.yml` itself
- `assets/videos/hero/*.mp4` (optional) — large files; upload separately via FTP if needed


title: Cache busting on cPanel
content:
cPanel does not handle cache headers automatically. Add version query strings
to CSS and JS references in HTML after every update:

```html
<link rel="stylesheet" href="/assets/css/styles.css?v=6">
<script src="/assets/js/main.js?v=3"></script>
```

Increment `v=` by 1 each time the file changes. Update across ALL HTML files.


title: Verify after deploy
content:
```bash
# From local machine — check key paths return 200
curl -s -o /dev/null -w "home:    %{http_code}\n" https://{domain}/
curl -s -o /dev/null -w "css:     %{http_code}\n" https://{domain}/assets/css/styles.css
curl -s -o /dev/null -w "404:     %{http_code}\n" https://{domain}/nonexistent-page
```

All public paths 200, all non-existent paths 404.


title: Resend account setup
content:
1. Sign up at https://resend.com
2. Generate an API key (one per project): https://resend.com/api-keys
3. Save the key in the project's `api/.env` as `RESEND_API_KEY=re_xxxx`
4. NEVER commit `.env`. NEVER paste the key in Slack, GitHub, or chat logs.


title: Add and verify the sending domain
content:
1. https://resend.com/domains → **Add Domain**
2. Enter the domain (the one you'll send FROM, not necessarily the website domain)
3. Resend gives 3-4 DNS records. Add them all in Cloudflare (or whatever DNS host)
4. Wait 5-15 minutes, click **Verify** in Resend — all records must show green


title: Records Resend provides
content:
| Type | Name | Value | Proxy | TTL |
|------|------|-------|-------|-----|
| TXT | `resend._domainkey` | `p=...long-rsa-key...` | DNS only | 1 hr |
| TXT | `send` | `v=spf1 include:amazonses.com ~all` | DNS only | 1 hr |
| MX  | `send` | `feedback-smtp.{region}.amazonses.com` priority 10 | DNS only | 1 hr |

(Resend uses Amazon SES under the hood, hence `amazonses.com` in the SPF.)


title: DMARC — REQUIRED for inbox placement
content:
Without DMARC, Gmail flags otherwise-correctly-configured email as suspicious
and routes it to spam. Resend doesn't auto-create this record. You must add it.

| Type | Name | Value | Proxy | TTL |
|------|------|-------|-------|-----|
| TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:dev@{domain}` | DNS only | Auto |

Components:
- `v=DMARC1` — declares a DMARC policy exists
- `p=none` — monitor mode, doesn't reject anything yet (safe to start)
- `rua=mailto:...` — DMARC failure reports go to this inbox (review weekly)

After 30 days of clean DMARC reports with no false positives, optionally
upgrade to `p=quarantine` then `p=reject`.


title: Verify DNS is live
content:
```bash
dig +short TXT resend._domainkey.{domain} @8.8.8.8
dig +short TXT send.{domain} @8.8.8.8
dig +short TXT _dmarc.{domain} @8.8.8.8
dig +short MX send.{domain} @8.8.8.8
```

All four should return their expected values.


title: From-name format
content:
Always use a friendly From name, not bare email. Bare email looks robotic
and triggers spam filters.

```
FROM_EMAIL=Brand Name <webleads@{domain}>
```


title: TO-email setup
content:
The `TO_EMAIL` is wherever the lead actually goes. Often a Gmail group address
or the owner's personal inbox.

- During Resend domain verification (BEFORE green): you can ONLY send TO the
  email tied to the Resend account
- After verification: send to anyone

For local testing without verification, use:
```
FROM_EMAIL=onboarding@resend.dev
TO_EMAIL={your-resend-account-email}
```


title: When emails go to spam
content:
Run this checklist:

1. **All 4 DNS records green at Resend**? If not, deliverability suffers.
2. **DMARC TXT record exists**? Most common cause of spam folder.
3. **Friendly From name**? `Brand Name <webleads@...>` not bare `webleads@...`
4. **Both `html` and `text` parts in the payload**? HTML-only is suspicious.
5. **Subject line clean**? No em-dashes, no "Estimate Request URGENT", no all-caps.
6. **Recipient marked first emails as Not Spam**? Train Gmail.


title: Resend regional considerations
content:
Resend operates from `us-east-1` (Virginia) by default. For email routing this
is fine globally, but if you need EU residency the `region` field at domain
creation accepts `eu-west-1`. Use this for clients in Europe.


title: When to use SMTP instead of REST API
content:
Resend supports both SMTP and REST API. We use the **REST API** because:
- Simpler from Python (urllib HTTPS POST, no smtplib state)
- Better error responses (JSON with `statusCode` and `message`)
- Idempotency-Key header support for deduplication
- Faster (HTTP keepalive vs SMTP handshake)

The SMTP path exists if a 3rd-party tool needs it (e.g., a CRM that only
speaks SMTP), but our Python service uses REST.


title: Cloudflare-specific notes
content:
The user-agent quirk (see `07-form-handling.md`) — Cloudflare in front of
Resend's API blocks Python's default `User-Agent: Python-urllib/3.x`. Always
set a custom `User-Agent` in the API request headers.

If the DNS provider is Cloudflare, ensure all Resend records have **proxy
status: DNS only** (the gray cloud icon, not orange). Proxying these breaks
authentication.


title: Annual key rotation
content:
Rotate Resend API keys annually:
1. Generate new key in Resend dashboard
2. Update `api/.env` on the server
3. `docker compose down && docker compose up -d` to reload env
4. Confirm a test submission still works
5. Revoke the old key in Resend dashboard


title: Resend HTTP 403 — Domain Not Verified
content:
A 403 from the Resend API does NOT mean the API key is wrong. The specific
error is:

```json
{"statusCode":403,"message":"The {domain} domain is not verified.
Please, add and verify your domain on https://resend.com/domains","name":"validation_error"}
```

This means the key is valid and authenticated, but the FROM domain has not
been added or verified at resend.com/domains yet.

Rule: **verify the domain BEFORE testing the form endpoint.** If you test
before verification, `{"ok":false}` will be returned to the visitor even
though the API key is correct and the code is correct.

Sequence:
1. Set `RESEND_API_KEY` in `.env`
2. Add domain at resend.com/domains
3. Add DNS records in Cloudflare
4. Wait for green verification
5. Then test the form endpoint

---


title: DKIM key rotation
content:
Resend periodically rotates DKIM keys. They send email when this happens. Add
the new `resend2._domainkey` (or whichever selector they specify) TXT record
in Cloudflare, then click verify. Old key remains active until they remove it.

---


title: SMTP Relay via msmtp (Docker containers with cPanel hosting)
content:
When a project uses Docker and the client has cPanel hosting email (e.g., `mail.ccgmail.us`), use `msmtp` as the sendmail replacement inside the container instead of the Resend API.


title: Dockerfile additions
content:
```dockerfile
RUN apk add --no-cache msmtp ca-certificates
```

Add to `infra/docker-entrypoint.sh`:

```sh
#!/bin/sh
if [ -n "${SMTP_HOST}" ]; then
    cat > /etc/msmtprc <<EOF
defaults
auth           on
tls            on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile        /dev/stderr

account        default
host           ${SMTP_HOST}
port           ${SMTP_PORT:-587}
from           ${MAIL_FROM:-noreply@domain.com}
user           ${SMTP_USER}
password       ${SMTP_PASS}
EOF
    chmod 600 /etc/msmtprc
fi
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
```

Set in `infra/php.ini`:
```ini
sendmail_path = /usr/bin/msmtp -a default -t
```


title: Required .env vars
content:
```
SMTP_HOST=mail.{cpanel-host}.com
SMTP_PORT=587
SMTP_USER=noreply@{domain}
SMTP_PASS={cPanel email password}
MAIL_FROM=noreply@{domain}
MAIL_FROM_NAME={Brand}
```


title: Spam filter headers (PHP mail())
content:
Always include `Date:` and `Message-ID:` in the headers — missing headers trigger spam filters:

```php
$message_id = '<' . time() . '.' . bin2hex(random_bytes(8)) . '@{domain}';
$headers = 'From: Brand <noreply@domain>' . "\r\n"
    . 'Date: ' . date('r') . "\r\n"
    . 'Message-ID: ' . $message_id . "\r\n"
    . 'MIME-Version: 1.0' . "\r\n";
```


title: When to use Resend vs msmtp
content:
| Situation | Use |
|-----------|-----|
| Client has no cPanel/SMTP credentials | Resend API |
| Client has cPanel hosting with email | msmtp + cPanel SMTP |
| Marketing/bulk email | Resend (SES infrastructure, better deliverability at scale) |
| Transactional only (magic links, download links) | Either — msmtp simpler if SMTP exists |


title: The Law
content:
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.


title: Build verification
content:
After running build scripts:

```bash
# Zero unreplaced placeholders
grep -rn "{{" site/locations/*.html site/services/*.html
# Result: empty
```


title: Container health
content:
```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
```


title: URL surface check
content:
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
```


title: Container content check
content:
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)
```


title: Mobile responsive check (Playwright)
content:
Save in `.planning/mobile_check.py`:

```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.


title: Form submission end-to-end
content:
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
```


title: Idempotency test
content:
Send the same payload twice. Both should return `{"ok":true}`. Inbox should
receive only ONE email (Resend deduplicates via Idempotency-Key).


title: Rate limit test
content:
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
```


title: SEO surface
content:
```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
```


title: Dash check (content rule)
content:
```bash
grep -rn '—\|–\|&mdash;\|&ndash;' site/ --include="*.html" --include="*.json"
# Result: empty
```


title: Lighthouse / PageSpeed (recommended)
content:
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


title: Browser test matrix
content:
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.


title: Pre-launch sign-off
content:
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


title: WHY THIS EXISTS
content:
Misrepresenting a clinician's licensure status on a public website violates New York State credentialing rules and can trigger disciplinary action from NYSED or the relevant licensing board. The cost of getting this wrong is not a correction — it is a formal complaint. This SOP exists so no Arising Media site ever publishes legally inaccurate credential copy.

---


title: Credential Tier Definitions (New York State)
content:
| Credential | Abbreviation | Public Label | Correct Framing |
|---|---|---|---|
| Licensed Mental Health Counselor | LMHC | Licensed | "Licensed Mental Health Counselor" |
| LMHC with Doctorate | LMHC-D | Licensed | "Licensed Mental Health Counselor, Doctoral Level" |
| Mental Health Counselor, Limited Permit | MHC-LP | Provisionally Licensed | "Provisionally Licensed Mental Health Counselor" |
| Licensed Clinical Social Worker | LCSW | Licensed | "Licensed Clinical Social Worker" |
| Licensed Master Social Worker | LMSW | Licensed | "Licensed Master Social Worker" |

**MHC-LP is NOT "Licensed Professional."** LP stands for Limited Permit. The correct public-facing term is **Provisionally Licensed**.

---


title: 1. Never use "Licensed" for a permit holder
content:
- MHC-LP, LMSW-candidate, or any other permit/pre-license designation must use "Provisionally Licensed" on the public site.
- Check: if the credential ends in `-LP`, `-candidate`, `-trainee`, or includes "Limited Permit" — it is not a full license.


title: 2. Credential label on credentials row
content:
- Full license holders → label: `License`
- Permit/provisional holders → label: `Permit`
- When in doubt, use `Credential`


title: 3. Supervision disclosure is mandatory
content:
- Any provisionally licensed or permit-holding clinician must display: `Practices under the supervision of [Name], [Credential]`
- This belongs in the footer disclaimer, the about page credentials row, and the auto-response email.
- Supervisor's credential must be accurate to the letter. LMHC ≠ LMHC-D. Always confirm the current designation before publishing.


title: 4. Practice entity in the footer
content:
- The footer disclaimer must state the legal practice entity name.
- A Vibrant You Counseling-style site shows the clinician's brand as the primary identity. The supervising PLLC may appear in the disclaimer only — not as a tagline or secondary brand.
- Do not add "A practice of [PLLC name]" as a visible footer tagline unless the client explicitly requests it.


title: 5. "Provisionally Licensed" vs "Licensed" — when in doubt
content:
- Use "Provisionally Licensed" for permit holders everywhere: credential row, why-cards, location page intros, section headers, auto-response emails, meta descriptions.
- Pattern for location/intro copy: `[Name], MHC-LP, is provisionally licensed in New York State and practices under the supervision of [Supervisor], [Credential].`

---


title: Credential Verification Checklist (Pre-Launch)
content:
- [ ] Confirmed clinician's current credential abbreviation directly with client
- [ ] Confirmed supervisor's current credential abbreviation directly with client
- [ ] No instance of "Licensed Professional" where LP = Limited Permit
- [ ] No instance of bare "Licensed" applied to a permit holder
- [ ] Footer disclaimer includes full supervision disclosure
- [ ] About page credential row uses correct label (License vs Permit)
- [ ] Auto-response email includes supervision disclosure
- [ ] All 40+ generated pages (location, service, definitions) use correct framing
- [ ] Run: `grep -r "Licensed Professional" --include="*.html"` — result must be zero

---


title: Common Mistakes
content:
| Wrong | Correct |
|---|---|
| MHC-LP, Mental Health Counselor, Licensed Professional | MHC-LP, Provisionally Licensed Mental Health Counselor |
| Licensed in New York State (for MHC-LP) | Provisionally licensed in New York State |
| Licensed & Specifically Trained (why-card for MHC-LP) | Provisionally Licensed & Specifically Trained |
| `<strong>License</strong>` (credential row for permit holder) | `<strong>Permit</strong>` |
| Ryan Sforza, LMHC (when designation is LMHC-D) | Ryan Sforza, LMHC-D |

---


title: When a Credential Changes
content:
Supervisor upgrades (LMHC → LMHC-D) or clinician advances (MHC-LP → LMHC) happen. When notified:

1. Run the bulk replace Python pattern from SOP 03 to update all HTML, PHP, PY, JSON files.
2. Update build/render.py and build/copy_library.py sources first — these regenerate the pages.
3. Rebuild the Docker container.
4. Re-run the grep verification check above.
5. Log the change in hop.db.


title: Format rule: WebP only
content:
- Every image in `assets/images/` is `.webp`
- Original JPGs and PNGs are generated/sourced then converted — they do NOT
  go into the repo or the Docker image
- HTML always references `.webp` paths, never `.jpg` or `.png`
- If an original JPG is found in the repo, run `tools/convert-to-webp.py`
  and delete the source file


title: Size targets
content:
| Image type | Max width | WebP quality | Target file size |
|---|---|---|---|
| Service card / thumbnail | 900px | 78 | 30–80KB |
| Hero image (page header) | 1400px | 80 | 50–180KB |
| OG / social share image | 1200px | 85 | under 150KB |

Images over these targets will be rejected at deploy time (visual review).


title: Conversion tool
content:
Every project has `tools/convert-to-webp.py`. Run it after adding or
regenerating any images:

```bash
cd /home/sirdrez/arisingmedia-websites/{domain}
python3 tools/convert-to-webp.py
```

What it does:
1. Scans `assets/images/services/` and `assets/images/hero/` for `.jpg` files
2. Resizes to max width (900px services, 1400px hero)
3. Saves `.webp` at target quality
4. Updates all `.html` files in the project to reference `.webp` instead of `.jpg`
5. Prints a before/after KB report

After running, manually delete the source `.jpg` files:
```bash
rm assets/images/services/*.jpg assets/images/hero/*.jpg
```

Then rebuild Docker or re-deploy so the new files are served.


title: Hero images — one per page
content:
Every page that uses a page-hero section gets its own unique hero image.
No two pages share the same hero. Naming convention:

```
hero-{page-slug}.webp
```

Examples:
```
hero-carpet-cleaning.webp    → services/carpet-cleaning/
hero-stairs.webp             → services/stairs/
hero-upholstery.webp         → services/upholstery/
hero-floors.webp             → services/floors/
hero-area-rugs.webp          → services/area-rugs/
hero-add-ons.webp            → services/add-ons/
hero-commercial.webp         → services/commercial/
hero-offices.webp            → commercial/offices/
hero-vacation-rentals.webp   → commercial/vacation-rentals/
hero-hotels.webp             → commercial/hotels-inns/
hero-retail.webp             → commercial/retail-showrooms/
hero-property-management.webp → commercial/property-management/
hero-about.webp              → about/
hero-service-area.webp       → service-area/
```


title: Image content rules
content:
- No people, no faces
- No cleaning machines, vacuums, steam equipment
- No text overlaid on source images
- Scene-based: show the RESULT (clean room, clean carpet, clean office)
  not the process (machine, worker, before state)
- Residential pages: warm home interiors — living rooms, stairs, bedrooms
- Commercial pages: professional spaces — offices, hotel corridors, lobbies


title: Why FLUX.1 Schnell
content:
FLUX is a 12B-parameter transformer model (vs SDXL's 3.5B). It has significantly
better spatial understanding: correct depth-to-space ratio, realistic lens
simulation, proper scene geometry. RealVisXL V5.0 (SDXL-based) was tested and
rejected — flat camera angles, no depth, poor spatial coherence.


title: Model stack (installed at `~/ComfyUI/`)
content:
| File | Location | Size | Purpose |
|---|---|---|---|
| `flux1-schnell-Q8_0.gguf` | `models/unet/` | 12GB | Main model (GGUF Q8_0) |
| `t5xxl_fp8_e4m3fn.safetensors` | `models/clip/` | 4.6GB | T5-XXL text encoder (long prompts) |
| `clip_l.safetensors` | `models/clip/` | 235MB | CLIP-L text encoder (short prompts) |
| `ae.safetensors` | `models/vae/` | 108MB | Official FLUX VAE (Black Forest Labs) |

Custom node required: `custom_nodes/ComfyUI-GGUF/` (handles GGUF quantized UNet)


title: VAE note
content:
`ae.safetensors` is from `black-forest-labs/FLUX.1-schnell` on HuggingFace.
The repo is gated — requires HF login AND accepting the license.
Use `hf auth login` with a read token before downloading:
```bash
HF_TOKEN=$(cat ~/.cache/huggingface/token)
wget --header="Authorization: Bearer $HF_TOKEN" \
  "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors" \
  -O ~/ComfyUI/models/vae/ae.safetensors
```


title: ComfyUI workflow (what gen-images-flux.py sends)
content:
```
UnetLoaderGGUF (flux1-schnell-Q8_0.gguf)
        |
DualCLIPLoader (t5xxl_fp8_e4m3fn + clip_l, type=flux)
        |
CLIPTextEncode (prompt)
        |
EmptyLatentImage (1024×576, batch=1)
        |
KSampler (steps=4, cfg=1.0, sampler=euler, scheduler=simple)
        |
VAEDecode (ae.safetensors)
        |
SaveImage → /ComfyUI/output/flux_lahr_*.png
```


title: Hardware requirements
content:
FLUX Q8_0 loads ~12GB into RAM (CPU mode). GPU requires 12GB+ VRAM.
With 2GB VRAM, run `--cpu` flag — ~4 min per image on CPU.


title: Starting ComfyUI
content:
```bash
# Start in tmux (stays alive after terminal closes)
tmux new-session -d -s comfyui \
  "cd ~/ComfyUI && venv/bin/python main.py --listen 0.0.0.0 --port 8188 --cpu 2>&1 | tee ~/comfyui.log"

# Wait for startup (~30s), verify:
curl -s http://localhost:8188/system_stats | python3 -m json.tool | head -5
```


title: Running generation
content:
```bash
cd /home/sirdrez/arisingmedia-websites/{domain}
python3 tools/gen-images-flux.py 2>&1 | tee tools/flux-gen.log
```

Monitor progress:
```bash
tmux attach -t comfyui      # see step progress bars
tail -f tools/flux-gen.log  # see per-image OK/FAIL
```


title: Prompt strategy for interior/carpet photography
content:
All prompts use explicit camera and lens language so FLUX renders correct depth:

```
low-angle 35mm lens perspective looking across [subject] in foreground,
[subject] razor sharp in foreground, [background elements] receding into bokeh,
shallow depth of field, [light description], no people, ultra-realistic [type] photography
```

Key terms that improve spatial rendering:
- `low-angle 35mm lens perspective` or `low 24mm wide-angle`
- `sharp in foreground ... receding into bokeh`
- `shallow depth of field`
- `vanishing point perspective` for corridors and offices


title: After generation completes
content:
```bash
python3 tools/convert-to-webp.py
rm assets/images/services/*.jpg assets/images/hero/*.jpg
docker compose build --no-cache web && docker compose up -d
```


title: vibrantyou.yoga specifics (2026-05-17)
content:
Remote ComfyUI machine: 10.20.10.10:8188
Model: flux1-schnell-Q4_K_S.gguf (Q4, smaller than Q8 — CPU only, ~5 min/image)
ROCm note: gfx1103 (Radeon 780M) segfaults on fp8 safetensors in samplers.py. GGUF only.
Restart ComfyUI: `/opt/ComfyUI/venv/bin/python3 /opt/ComfyUI/main.py --listen --port 8188 --cpu &`

Generation strategy:
- 4 variants per image slot (28 total)
- No people — props, interiors, flat lays only
- Output to `assets/images/generated/{name}_v{1-4}.webp`
- Pick best variant, copy to `assets/images/{final-name}.webp`
- Script: `.planning/generate_images.py`

Yoga image content rules:
- No people, no faces, no body parts
- Yoga props: mats, blocks, straps, bolsters, cushions, candles, singing bowls
- Studio interiors: wooden floor, plants, natural light, sage green/white tones
- Flat lay overhead or close-up detail shots
- Lifestyle: mat + laptop, mat + plant, mat + props


title: After adding images — checklist
content:
1. Run `tools/convert-to-webp.py`
2. Delete source `.jpg` files
3. Bump CSS/JS cache version in all HTML (`?v=N`)
4. Rebuild Docker (`docker compose build --no-cache web && docker compose up -d`)
5. Verify: `curl -s -o /dev/null -w "%{http_code}" http://localhost:{port}/assets/images/hero/hero-carpet-cleaning.webp`
   — must return 200
6. Verify old `.jpg` returns 404


title: Why we do this
content:
Single full-page screenshots flatten sticky and pinned scroll behavior. We need motion captured as video plus frame-by-frame stills to see where sections actually transition. Without this we end up with flat white templates that look nothing like the reference.


title: Inputs
content:
- Reference URL (single canonical product/landing page is enough)
- Output folder: `<project>/.planning/<refname>-reference/`


title: Outputs (every audit must produce all six)
content:
1. `<refname>-reference/scroll-capture/desktop-scroll.webm` — full scroll, 1440×900, slow (≈30s).
2. `<refname>-reference/scroll-capture/mobile-scroll.webm` — full scroll, 390×844.
3. `<refname>-reference/scroll-capture/frames/desktop/frame_*.png` — every 250px scrollY.
4. `<refname>-reference/scroll-capture/frames/mobile/frame_*.png` — every 250px scrollY.
5. `<refname>-reference/scroll-capture/frames/desktop-annotated/T01..Tn_*.png` — before/during/after for every detected pin-spacer or sticky transition.
6. `MINISFORUM_REFERENCE.md` (rename for the site) — page framing, section inventory, typography, color, imagery strategy, scroll effects, mobile-vs-desktop, components-to-borrow, mapping to our schema columns.

Plus one rolled-up HTML report (`<REFNAME>_VISUAL_REPORT.html`) with the videos and frame strips embedded so Andre can open one file in Firefox.


title: Browser policy
content:
Firefox only. Playwright `firefox.launch(headless=False)` for verification screenshots. Never chromium.


title: Capture procedure (Playwright Firefox)
content:
1. Goto URL, wait `networkidle`.
2. Get full document height `document.scrollingElement.scrollHeight`.
3. Start `page.video` recording (record_video_dir set on context).
4. Scroll loop: from 0 to scrollHeight, step 8–12 px, sleep 16 ms — yields a smooth ~30s video.
5. At every 250 px boundary save a screenshot to `frames/desktop/frame_<scrollY>.png`.
6. Detect transitions: for every `aside`, `[data-section]`, `.shopify-section`, `.pin-spacer`, capture `getBoundingClientRect()` + `position` + `top` at every step. A transition is logged when `position` changes (e.g. relative→sticky→relative) or when a parent pin-spacer height changes. Write the list to `frames/scroll-events.json`.
7. For each detected transition T(n), save three annotated frames at scrollY-200, scrollY, scrollY+200 with overlay text `Tn: <element-selector>` and `y=<scrollY>` to `frames/desktop-annotated/`.
8. Repeat 1–7 at 390×844 for mobile.


title: Skeleton extraction
content:
For the canonical reference page produce:
- z-index map (sticky + fixed elements, with px heights)
- pinned-zone inventory: count, scroll distance held, panel count per zone
- inter-section spacing rhythm
- color band rhythm (which sections share a bg color, which alternate)
- typography scale (h1/h2/h3/body in px and line-height)
- imagery slot list (hero bg, hero illustration, trust logos, pin sticky images, deliverable icons, spec icons, testimonial avatars, og defaults)


title: Schema mapping
content:
Every distinct section in the reference must be assigned to one of our schema columns or get a new column added in a versioned migration (`build/migrations/YYYY_MM_DD_schema_vN.sql`). The skeleton is then the contract: every page type declares which slots are active, the renderer skips null slots silently.


title: Application gates
content:
- Designs land in `.planning/WIREFRAME_SKETCH_<DATE>.md` first (ASCII skeleton with scrollY ranges).
- CSS additions live in a new section of `main.css` titled `/* === Section bands & scroll-reveal (ref: <REFNAME>) === */`.
- JS additions to `main.js` only for IntersectionObserver-driven reveals; GSAP ScrollTrigger only if pin zones are required.
- Verify with `verify-protection.sh` after deploy — the audit folder is `.planning/` only, never shipped to `src/`.


title: Anti-patterns (do not do)
content:
- Single full-page screenshot for a long-scroll page (loses sticky/pin info).
- Chromium / chrome / chrome-headless captures (per CLAUDE.md).
- Inventing slots that the reference does not have just because we want them.
- Touching `Dockerfile`, nginx config, or shipping the `.planning/<refname>-reference/` folder to production.


title: Done definition
content:
Andre can open `<REFNAME>_VISUAL_REPORT.html` in Firefox and (a) see both scroll videos play, (b) page through every T(n) annotated triplet, (c) read the skeleton, (d) see the mapping table from reference section → our schema column.


title: When to use this pattern instead of static HTML
content:
- File uploads and processing (image conversion, PDF generation, etc.)
- At-rest encryption of user data
- Payment processing with Stripe subscriptions
- User authentication with magic link or password-based login
- Rate-limited APIs that must be server-enforced

**Do not** introduce this pattern just to add a contact form. Use the Python stdlib form service from SOP 07 instead.

---


title: Stack
content:
- **PHP 8.3** (php:8.3-fpm-alpine base image)
- **Nginx** (Alpine package, same container via supervisord)
- **SQLite** (pdo_sqlite extension, no separate DB container needed)
- **libsodium** (built into PHP 8.x — use for all encryption)
- **ImageMagick** (pecl imagick for image processing)
- **msmtp** (SMTP relay for outbound email — see SOP 09)
- **supervisord** (manages nginx + php-fpm + crond in one container)

---


title: Project structure
content:
```
project/
├── src/                    ← nginx document root
│   ├── index.php
│   ├── api/
│   │   ├── convert.php     ← POST endpoint (CSRF + reCAPTCHA protected)
│   │   └── download.php    ← GET endpoint (signed token)
│   ├── assets/css/
│   ├── assets/js/
│   └── assets/images/
├── includes/               ← PHP classes (above doc root, not web-accessible)
│   ├── bootstrap.php       ← constants, session, autoload
│   ├── auth.php            ← login, register, magic token
│   ├── csrf.php
│   ├── db.php              ← SQLite PDO wrapper
│   ├── encryption.php      ← libsodium wrappers
│   └── mailer.php
├── components/
│   ├── header.php
│   └── footer.php
├── storage/                ← volume-mounted, NOT in docker image
│   ├── uploads/            ← encrypted .enc files only
│   ├── converted/
│   ├── temp/
│   ├── .htaccess           ← deny all direct access
│   └── {app}.db
├── infra/
│   ├── nginx.conf
│   ├── php.ini
│   ├── supervisord.conf
│   └── docker-entrypoint.sh
├── tools/
│   └── cleanup.php         ← cron: delete expired tokens + files
├── Dockerfile
├── docker-compose.yml
└── .env                    ← gitignored, never committed
```

---


title: Security requirements (non-negotiable)
content:
**CSRF** — every POST form and API endpoint must verify a CSRF token tied to the session.

**Rate limiting** — two layers:
1. nginx: `limit_req_zone` on /api/ (10 req/s, burst 20)
2. PHP: per-IP daily counter in SQLite rate_limits table

**reCAPTCHA v3** — on conversion/upload endpoints. Verify server-side via Google API. Cache result in session (verify once per session, not per request).

**At-rest encryption** — any user-uploaded file must be encrypted before writing to disk. Use `sodium_crypto_secretstream_xchacha20poly1305_*` for files, `sodium_crypto_secretbox` for strings. Key stored in `.env` as `QC_ENCRYPTION_KEY` (32 bytes hex).

**Signed download tokens** — never expose file paths. Issue a 64-char hex token stored in SQLite with expiry and single-use enforcement.

**Magic link auth** — prefer magic link over password. On register: create account unverified, send verify email, block login until verified. Token: 64-char hex, 1-hour expiry, stored in `magic_tokens` table, consumed on use.

---


title: nginx security headers (required)
content:
```nginx
add_header X-Frame-Options           "SAMEORIGIN" always;
add_header X-Content-Type-Options    "nosniff" always;
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Permissions-Policy        "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy   "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; form-action 'self' https://checkout.stripe.com;" always;

# Stripe webhook — POST only
location = /api/stripe-webhook.php {
    limit_except POST { deny all; }
}

# Block dotfiles
location ~ /\. { deny all; return 403; }
```

---


title: Database schema pattern (SQLite, idempotent)
content:
Use `CREATE TABLE IF NOT EXISTS` for all tables. Use `ALTER TABLE ... ADD COLUMN` wrapped in try/catch for schema migrations — SQLite does not support `IF NOT EXISTS` on columns.

```php
try { $pdo->exec("ALTER TABLE users ADD COLUMN verified_at INTEGER DEFAULT NULL"); }
catch (Throwable $e) { /* column already exists */ }
```

---


title: Stripe integration
content:
- Checkout: create session server-side, redirect to Stripe-hosted page
- Webhook: verify `Stripe-Signature` header using HMAC-SHA256 (implement without Stripe SDK — use curl)
- Webhook tolerance: 300 seconds (5 min) on timestamp
- Register webhook endpoint at: `https://{domain}/api/stripe-webhook.php`
- Events to subscribe: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`

---


title: .env required vars
content:
```
APP_ENV=production
BASE_URL=https://{domain}
QC_ENCRYPTION_KEY={32-bytes-hex}
STRIPE_MODE=live
STRIPE_LIVE_SECRET_KEY=sk_live_...
STRIPE_LIVE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_ID=price_...
RECAPTCHA_SITE_KEY=...
RECAPTCHA_SECRET_KEY=...
SMTP_HOST=...
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
MAIL_FROM=noreply@{domain}
MAIL_FROM_NAME={Brand}
```

Generate encryption key: `php -r "echo bin2hex(random_bytes(32));"`


