[object Object]

← back to Petitionyour

Initial build: Petition Your Representatives MVP

4abdcb4a1f9af171d2b65c0eb20870267cfac114 · 2026-09-09 11:30:51 -0700 · Steve Abrams

Real working civic petition tool for petitionyour.org — browse/create/sign
petitions with live signature counts + share links, and a free ($0, no
paid API) ZIP-to-state representative lookup that links to official .gov
sources instead of a static (and inevitably stale) officeholder list.

Express + EJS, JSON-file store (atomic writes, zero native deps), dark/light
toggle + sort/density grid controls per standing rules. Local-only — no
prod deploy fired; nginx vhost (IP-bound :443 per the fleet HTTPS-000
lesson), ecosystem.config.js, and a deploy-kamatera.sh runbook are staged
as gated artifacts for Steve to run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr

Files touched

Diff

commit 4abdcb4a1f9af171d2b65c0eb20870267cfac114
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 11:30:51 2026 -0700

    Initial build: Petition Your Representatives MVP
    
    Real working civic petition tool for petitionyour.org — browse/create/sign
    petitions with live signature counts + share links, and a free ($0, no
    paid API) ZIP-to-state representative lookup that links to official .gov
    sources instead of a static (and inevitably stale) officeholder list.
    
    Express + EJS, JSON-file store (atomic writes, zero native deps), dark/light
    toggle + sort/density grid controls per standing rules. Local-only — no
    prod deploy fired; nginx vhost (IP-bound :443 per the fleet HTTPS-000
    lesson), ecosystem.config.js, and a deploy-kamatera.sh runbook are staged
    as gated artifacts for Steve to run.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01JDeaFL4eeREBZX79tc4cnr
---
 .gitignore                         |   8 +
 README.md                          | 226 +++++++++
 data/petitions.json                |  35 ++
 data/signatures.json               |  35 ++
 data/zip3-state-ranges.json        |  58 +++
 deploy-kamatera.sh                 |  97 ++++
 deploy/nginx-petitionyour.org.conf |  94 ++++
 ecosystem.config.js                |  18 +
 lib/petitions.js                   | 155 +++++++
 lib/reps.js                        |  38 ++
 lib/store.js                       |  36 ++
 package-lock.json                  | 928 +++++++++++++++++++++++++++++++++++++
 package.json                       |  18 +
 public/css/site.css                | 224 +++++++++
 public/js/site.js                  | 129 ++++++
 server.js                          | 127 +++++
 views/404.ejs                      |   8 +
 views/find-reps.ejs                |  32 ++
 views/index.ejs                    |  75 +++
 views/new.ejs                      |  45 ++
 views/partials/foot.ejs            |  18 +
 views/partials/head.ejs            |  34 ++
 views/petition.ejs                 | 102 ++++
 23 files changed, 2540 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c43c47e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,226 @@
+# petitionyour.org — Petition Your Representatives
+
+A real, working civic petition MVP: browse petitions, start one, sign one
+(live signature count + share links), and look up your representatives by
+ZIP — free path only, no paid APIs.
+
+## Status
+
+- **Local build: done, working, git-committed.**
+- **Prod deploy: NOT fired.** DNS for `petitionyour.org` already points at
+  Kamatera (`45.61.58.125`), but there is no nginx vhost or SSL cert for it
+  yet — see "Deploy runbook" below, which is a Steve-run/approved paste, not
+  something this session executed.
+- Heads-up: there is an **unrelated, pre-existing, broken** pm2 app also
+  named `petitionyour` on Kamatera serving **petitionyour.COM** (not .org) —
+  a crash-looping Next.js build (`/root/public-projects/petitionyour`,
+  30 restarts, "Failed to find Server Action" errors, nginx vhost dated
+  Aug 17). It is untouched by this build. This project intentionally uses
+  a different pm2 name (`petitionyour-org`) and a different remote directory
+  (`/root/public-projects/petitionyour-org`) so the two can never collide.
+  Worth a cleanup pass on the .com app separately, out of scope here.
+
+## Stack
+
+- Express 4 + EJS server-rendered views (no build step, no client framework)
+- Durable storage: **plain JSON files** (`data/petitions.json`,
+  `data/signatures.json`) via `lib/store.js` — atomic writes (temp file +
+  rename), synchronous reads (Node is single-threaded, so no interleaved
+  writes to worry about at this scale). Chosen over sqlite for the deploy:
+  zero native deps, zero node-gyp/build-toolchain risk on Kamatera, fully
+  git-diffable, and trivially reversible (it's just JSON — restore a git
+  revision to roll back any bad write).
+- Dark/light toggle from day one (Steve's standing rule) — anti-flash inline
+  script + CSS vars in `public/css/site.css`, pattern lifted from
+  `restaurant-directory`.
+- Sort + density controls on the browse grid (Steve's standing rule) —
+  `public/js/site.js`, localStorage-persisted (`petitionyour:sort`,
+  `petitionyour:density`), plus search + category filter.
+
+## Local preview
+
+```sh
+cd ~/Projects/petitionyour
+npm install
+npm run dev          # PORT=4000 node server.js
+open http://127.0.0.1:4000/
+```
+
+Or `npm start` for `PORT=4000` (or set your own `PORT=xxxx node server.js`).
+Health check: `curl http://127.0.0.1:4000/health` → `ok`.
+
+Seed data ships 3 example petitions with a few example signatures so the
+grid isn't empty on first load — safe to leave, or delete
+`data/petitions.json` / `data/signatures.json` (they degrade gracefully to
+`[]` if missing).
+
+## Data model
+
+`data/petitions.json` — array of:
+```
+{ id, slug, title, description, target, targetType, category, creatorName, createdAt }
+```
+
+`data/signatures.json` — array of:
+```
+{ id, petitionId, name, email, zip, comment, showNamePublicly, updatesOptIn, createdAt }
+```
+
+Signature count is always computed live from `signatures.json` (never
+stored/cached on the petition record), so it can't drift.
+
+One signature per (petition, email) — duplicate signing attempts 400.
+
+**Privacy / consent**: email is required (to prevent duplicate signing) but
+never shown publicly. Name is shown publicly only if the signer checks
+"show my name publicly" (default on, easy to uncheck for anonymous signing).
+`updatesOptIn` is captured as a consent flag only — **no email-sending
+mechanism is wired**, per the task's hard rule (no send-to-list without
+Steve). If/when an update-email feature is built, it must read this flag
+and include an unsubscribe link, per the same rule.
+
+## Find-your-representatives (ZIP lookup) — design decision
+
+Free path only, and **honest about coverage**:
+
+- `data/zip3-state-ranges.json` is a bundled, offline ZIP3-prefix → state
+  range table (standard USPS allocation ranges, covers 50 states + DC +
+  PR/VI/Guam). `lib/reps.js` resolves a ZIP to a state deterministically,
+  $0, no network call.
+- We deliberately do **not** bundle a static list of senator/representative
+  *names*. Officeholders change (elections, resignations, special
+  elections, appointments) and my knowledge cutoff is already months stale
+  — a hardcoded name list would be silent civic misinformation, which is
+  worse than no data on a "credible, trustworthy .org" site. Instead, once
+  we know the state, we hand back direct links to the **official, always-
+  current** .gov lookup tools:
+  - House.gov's own ZIP-based "Find Your Representative" tool
+  - Senate.gov's senator contact list
+  - Congress.gov member search
+  - USA.gov's elected-officials-by-address tool
+- This is the "or link out" option the brief explicitly sanctioned as a
+  reasonable v1. `GET /find-reps` (page) and `GET /api/find-reps?zip=`
+  (JSON, used by the inline widget on every petition detail page) both use
+  it. Tested against 60614→IL, 90210→CA, 10001→NY — all correct.
+
+## Routes
+
+| Route | Method | What |
+|---|---|---|
+| `/` | GET | Browse petitions (sort + density + search + category filter) |
+| `/petitions/new` | GET/POST | Create a petition |
+| `/petitions/:slug` | GET | Detail page — description, sign form, share links, supporters, reps widget |
+| `/petitions/:slug/sign` | POST | Sign (name + email required, ZIP optional) |
+| `/find-reps` | GET | Standalone ZIP → state → official links page |
+| `/api/find-reps?zip=` | GET | JSON version of the same lookup |
+| `/health` | GET | 200 `ok` — deploy smoke-test target |
+
+## Deploy runbook (GATED — Steve runs this, nothing here has been fired)
+
+Nothing in this section has been executed. Mac2 is also classifier-blocked
+from prod SSH writes, so this is the paste Steve (or an approved session)
+runs by hand, in order:
+
+**1. nginx vhost + reload (verify `nginx -t` clean before reload — one bad
+vhost freezes reload for the WHOLE box, see `kamatera-nginx-canary`):**
+
+```sh
+! scp ~/Projects/petitionyour/deploy/nginx-petitionyour.org.conf \
+    root@45.61.58.125:/etc/nginx/sites-available/petitionyour.org
+! ssh root@45.61.58.125 "ln -sf /etc/nginx/sites-available/petitionyour.org /etc/nginx/sites-enabled/petitionyour.org && nginx -t"
+```
+
+If `nginx -t` reports OK, reload:
+```sh
+! ssh root@45.61.58.125 "systemctl reload nginx"
+```
+If it fails, **do not reload** — fix the vhost first (the shipped template
+has the HTTPS `listen [::]:443 ssl;` line commented out by default because
+novasuede.com's live vhost hit a tailscale-holds-v6:443 conflict on this
+exact box; only uncomment it after confirming there's no conflict).
+
+Note: the vhost's HTTPS server block references a cert that doesn't exist
+yet (`/etc/letsencrypt/live/petitionyour.org/...`) — nginx will fail to
+start THAT block until step 2 issues the cert. Standard certbot chicken-
+and-egg; the usual fix is requesting the cert with `--nginx` (which edits
+the vhost itself) or temporarily commenting the `ssl_certificate` lines
+until after step 2. Steve/whoever runs this should use whichever certbot
+plugin is already standard for this box (check how novasuede.com's cert
+was issued: `ssh root@45.61.58.125 "certbot certificates | grep -A3 novasuede"`).
+
+**2. Let's Encrypt cert:**
+
+```sh
+! ssh root@45.61.58.125 "certbot --nginx -d petitionyour.org -d www.petitionyour.org"
+```
+
+**3. Deploy the app (rsync + npm install + pm2 + health check):**
+
+```sh
+! cd ~/Projects/petitionyour && ./deploy-kamatera.sh
+```
+
+Or dry-run first to see exactly what it would do:
+```sh
+! cd ~/Projects/petitionyour && ./deploy-kamatera.sh --dry-run
+```
+
+**4. Verify all four layers** (the script's `health_check` does this, or
+run it standalone):
+```sh
+! cd ~/Projects/petitionyour && ./deploy-kamatera.sh --health-only
+```
+- origin direct (HTTP via Host header) → 200
+- CF proxied (HTTPS) → 200
+- SSL cert valid (openssl dates) → not expired, matches petitionyour.org
+- www subdomain → 200
+
+**5. Cloudflare status — checked (read-only, no changes made):**
+`petitionyour.org` is still on **GoDaddy nameservers** (`ns03/ns04.
+domaincontrol.com`), NOT Cloudflare's — its A record points straight at
+`45.61.58.125` with no CF proxy in front. (For contrast, `petitionyour.COM`
+— the unrelated pre-existing app — IS on Cloudflare nameservers.) This
+matches the `losangelesfabrics.com` precedent noted in the onboard-domain-
+agent playbook: NS still on DOMAINCONTROL.COM, Cloudflare onboarding
+pending a Steve go-ahead.
+
+Two honest options for Steve to choose between — **this session picked
+neither; both are gated:**
+- **A — ship now, direct-to-origin SSL.** Since DNS isn't behind Cloudflare,
+  `certbot --nginx` in step 2 will complete a normal HTTP-01 challenge with
+  no CF interference. Fastest path to live. The vhost's `set_real_ip_from`
+  Cloudflare blocks are harmless no-ops until/unless Cloudflare is added
+  later.
+- **B — onboard to Cloudflare first** (CF proxy, WAF, analytics, matches
+  the rest of the fleet) via the `/domain-setup` skill's zone-create +
+  NS-swap flow, *then* deploy. This is the "irreversible-ish" NS-swap
+  moment the skill explicitly says to surface to Steve and only proceed on
+  his explicit OK — so it's a separate gated step, not bundled into this
+  runbook.
+
+**6. GA4** (DW spec checklist — analytics before going live):
+```sh
+! python3 ~/.claude/skills/analytics/scripts/create_property.py --name "Petition Your Representatives" --domain "petitionyour.org"
+! python3 ~/.claude/skills/analytics/scripts/inject_gtag.py --project ~/Projects/petitionyour --measurement-id G-XXXXXXXX
+```
+(Fill in the real measurement ID the first script returns.)
+
+**7. Reconcile to CNCP** after it's live:
+Edit `~/cncp-starter/cncp-config.json` → this domain's entry → `ip:
+"45.61.58.125"`, `statusLabel: "Live"`, `dnsHost: "Cloudflare"` (if
+Cloudflare-fronted) → restart CNCP.
+
+## Known gaps / honest limitations (v1)
+
+- ZIP→state coverage is approximate (standard USPS prefix ranges); a
+  handful of edge-case ZIPs may not resolve — the UI says so and links to
+  the official lookups directly.
+- No House-district-level lookup (that needs precise geocoding, which isn't
+  free/simple) — we link to House.gov's own official ZIP-based finder
+  instead of building our own.
+- No moderation/review queue on petition creation — publishes immediately,
+  as stated on the create form.
+- No email verification loop on signing (email is validated for format +
+  uniqueness only, not confirmed via a sent link) — keeps v1 simple and
+  avoids any outbound email wiring, which is explicitly out of scope until
+  Steve approves a send flow with proper consent/unsubscribe.
diff --git a/data/petitions.json b/data/petitions.json
new file mode 100644
index 0000000..e4f36bf
--- /dev/null
+++ b/data/petitions.json
@@ -0,0 +1,35 @@
+[
+  {
+    "id": "8f14e45f-ceea-467e-bd3e-000000000001",
+    "slug": "repave-elm-street-before-winter",
+    "title": "Repave Elm Street Before Winter",
+    "description": "Elm Street between 3rd and 9th Ave has gone three winters without repair. The potholes are damaging cars and creating a real hazard for cyclists and the school-bus route. We're asking the city to add this stretch to this year's paving budget before the first freeze.",
+    "target": "City Public Works Department",
+    "targetType": "City Council",
+    "category": "Local & City",
+    "creatorName": "A concerned neighbor",
+    "createdAt": "2026-08-12T14:00:00.000Z"
+  },
+  {
+    "id": "8f14e45f-ceea-467e-bd3e-000000000002",
+    "slug": "protect-funding-for-school-lunch-programs",
+    "title": "Protect Funding for School Lunch Programs",
+    "description": "Proposed budget cuts would eliminate free lunch for roughly 1,200 students in our district. We're asking our state representatives to restore full funding in the next budget cycle so no child goes without a meal during the school day.",
+    "target": "State Assembly Education Committee",
+    "targetType": "State Legislature",
+    "category": "Education",
+    "creatorName": "Parent-Teacher Coalition",
+    "createdAt": "2026-08-20T09:30:00.000Z"
+  },
+  {
+    "id": "8f14e45f-ceea-467e-bd3e-000000000003",
+    "slug": "support-the-clean-waterways-act",
+    "title": "Support the Clean Waterways Act",
+    "description": "Runoff from upstream industrial sites has raised nitrate levels in the river that supplies drinking water to three counties. We're calling on our members of Congress to co-sponsor and vote yes on the Clean Waterways Act to fund monitoring and cleanup.",
+    "target": "U.S. House of Representatives",
+    "targetType": "Federal / Congress",
+    "category": "Environment",
+    "creatorName": "River Basin Watch",
+    "createdAt": "2026-08-28T17:45:00.000Z"
+  }
+]
diff --git a/data/signatures.json b/data/signatures.json
new file mode 100644
index 0000000..80f41ef
--- /dev/null
+++ b/data/signatures.json
@@ -0,0 +1,35 @@
+[
+  {
+    "id": "a1b2c3d4-0000-0000-0000-000000000001",
+    "petitionId": "8f14e45f-ceea-467e-bd3e-000000000001",
+    "name": "Maria Gonzalez",
+    "email": "seed-signature-1@example.invalid",
+    "zip": "60614",
+    "comment": "I bent a rim on that pothole by the school last month.",
+    "showNamePublicly": true,
+    "updatesOptIn": false,
+    "createdAt": "2026-08-13T10:15:00.000Z"
+  },
+  {
+    "id": "a1b2c3d4-0000-0000-0000-000000000002",
+    "petitionId": "8f14e45f-ceea-467e-bd3e-000000000001",
+    "name": "Anonymous supporter",
+    "email": "seed-signature-2@example.invalid",
+    "zip": "60614",
+    "comment": "",
+    "showNamePublicly": false,
+    "updatesOptIn": false,
+    "createdAt": "2026-08-14T08:00:00.000Z"
+  },
+  {
+    "id": "a1b2c3d4-0000-0000-0000-000000000003",
+    "petitionId": "8f14e45f-ceea-467e-bd3e-000000000002",
+    "name": "David Chen",
+    "email": "seed-signature-3@example.invalid",
+    "zip": "94110",
+    "comment": "Kids should never have to worry about lunch.",
+    "showNamePublicly": true,
+    "updatesOptIn": false,
+    "createdAt": "2026-08-21T12:00:00.000Z"
+  }
+]
diff --git a/data/zip3-state-ranges.json b/data/zip3-state-ranges.json
new file mode 100644
index 0000000..4ea62ec
--- /dev/null
+++ b/data/zip3-state-ranges.json
@@ -0,0 +1,58 @@
+[
+  { "start": 0, "end": 4, "state": "NY", "name": "New York" },
+  { "start": 5, "end": 5, "state": "NY", "name": "New York" },
+  { "start": 6, "end": 9, "state": "PR", "name": "Puerto Rico / Virgin Islands" },
+  { "start": 10, "end": 27, "state": "MA", "name": "Massachusetts" },
+  { "start": 28, "end": 29, "state": "RI", "name": "Rhode Island" },
+  { "start": 30, "end": 38, "state": "NH", "name": "New Hampshire" },
+  { "start": 39, "end": 49, "state": "ME", "name": "Maine" },
+  { "start": 50, "end": 59, "state": "VT", "name": "Vermont" },
+  { "start": 60, "end": 69, "state": "CT", "name": "Connecticut" },
+  { "start": 70, "end": 89, "state": "NJ", "name": "New Jersey" },
+  { "start": 100, "end": 149, "state": "NY", "name": "New York" },
+  { "start": 150, "end": 196, "state": "PA", "name": "Pennsylvania" },
+  { "start": 197, "end": 199, "state": "DE", "name": "Delaware" },
+  { "start": 200, "end": 205, "state": "DC", "name": "District of Columbia" },
+  { "start": 206, "end": 219, "state": "MD", "name": "Maryland" },
+  { "start": 220, "end": 246, "state": "VA", "name": "Virginia" },
+  { "start": 247, "end": 268, "state": "WV", "name": "West Virginia" },
+  { "start": 270, "end": 289, "state": "NC", "name": "North Carolina" },
+  { "start": 290, "end": 299, "state": "SC", "name": "South Carolina" },
+  { "start": 300, "end": 319, "state": "GA", "name": "Georgia" },
+  { "start": 320, "end": 349, "state": "FL", "name": "Florida" },
+  { "start": 350, "end": 369, "state": "AL", "name": "Alabama" },
+  { "start": 370, "end": 385, "state": "TN", "name": "Tennessee" },
+  { "start": 386, "end": 397, "state": "MS", "name": "Mississippi" },
+  { "start": 398, "end": 399, "state": "GA", "name": "Georgia" },
+  { "start": 400, "end": 427, "state": "KY", "name": "Kentucky" },
+  { "start": 430, "end": 459, "state": "OH", "name": "Ohio" },
+  { "start": 460, "end": 479, "state": "IN", "name": "Indiana" },
+  { "start": 480, "end": 499, "state": "MI", "name": "Michigan" },
+  { "start": 500, "end": 528, "state": "IA", "name": "Iowa" },
+  { "start": 530, "end": 549, "state": "WI", "name": "Wisconsin" },
+  { "start": 550, "end": 567, "state": "MN", "name": "Minnesota" },
+  { "start": 570, "end": 577, "state": "SD", "name": "South Dakota" },
+  { "start": 580, "end": 588, "state": "ND", "name": "North Dakota" },
+  { "start": 590, "end": 599, "state": "MT", "name": "Montana" },
+  { "start": 600, "end": 629, "state": "IL", "name": "Illinois" },
+  { "start": 630, "end": 658, "state": "MO", "name": "Missouri" },
+  { "start": 660, "end": 679, "state": "KS", "name": "Kansas" },
+  { "start": 680, "end": 693, "state": "NE", "name": "Nebraska" },
+  { "start": 700, "end": 714, "state": "LA", "name": "Louisiana" },
+  { "start": 716, "end": 729, "state": "AR", "name": "Arkansas" },
+  { "start": 730, "end": 749, "state": "OK", "name": "Oklahoma" },
+  { "start": 750, "end": 799, "state": "TX", "name": "Texas" },
+  { "start": 800, "end": 816, "state": "CO", "name": "Colorado" },
+  { "start": 820, "end": 831, "state": "WY", "name": "Wyoming" },
+  { "start": 832, "end": 838, "state": "ID", "name": "Idaho" },
+  { "start": 840, "end": 847, "state": "UT", "name": "Utah" },
+  { "start": 850, "end": 865, "state": "AZ", "name": "Arizona" },
+  { "start": 870, "end": 884, "state": "NM", "name": "New Mexico" },
+  { "start": 889, "end": 898, "state": "NV", "name": "Nevada" },
+  { "start": 900, "end": 961, "state": "CA", "name": "California" },
+  { "start": 967, "end": 968, "state": "HI", "name": "Hawaii" },
+  { "start": 969, "end": 969, "state": "GU", "name": "Guam / Pacific territories" },
+  { "start": 970, "end": 979, "state": "OR", "name": "Oregon" },
+  { "start": 980, "end": 994, "state": "WA", "name": "Washington" },
+  { "start": 995, "end": 999, "state": "AK", "name": "Alaska" }
+]
diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
new file mode 100755
index 0000000..00a10cb
--- /dev/null
+++ b/deploy-kamatera.sh
@@ -0,0 +1,97 @@
+#!/bin/bash
+# petitionyour.org — Kamatera deploy
+#
+# GATED — this script is NOT run automatically by any agent. Steve runs it
+# himself (or approves the exact `!` commands in the paste-safe runbook).
+#
+# Pre-reqs this script does NOT do for you (see README.md "Deploy runbook"):
+#   1. nginx vhost installed at /etc/nginx/sites-available/petitionyour.org
+#      (copy deploy/nginx-petitionyour.org.conf) + `nginx -t` verified clean
+#      + `systemctl reload nginx` — BEFORE requesting the cert (port 80 must
+#      already answer petitionyour.org for certbot's HTTP-01 challenge).
+#   2. Let's Encrypt cert via certbot for petitionyour.org + www.petitionyour.org
+#   3. Node + pm2 already installed on Kamatera (they are, fleet-wide)
+#
+# What this script DOES (idempotent, safe to re-run):
+#   1. rsync project to Kamatera at /root/public-projects/petitionyour-org
+#   2. npm install --omit=dev
+#   3. pm2 startOrReload ecosystem.config.js + pm2 save
+#   4. 4-layer health check (origin direct / CF proxied / SSL valid / www)
+#
+# Usage:
+#   ./deploy-kamatera.sh                # full deploy
+#   ./deploy-kamatera.sh --dry-run      # show what would run, no changes
+#   ./deploy-kamatera.sh --health-only  # only run the post-deploy health check
+
+set -euo pipefail
+
+KAMATERA="root@45.61.58.125"
+LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
+REMOTE_DIR="/root/public-projects/petitionyour-org"
+PM2_NAME="petitionyour-org"
+DOMAIN="petitionyour.org"
+PORT="9730"
+
+DRY_RUN=false
+HEALTH_ONLY=false
+case "${1:-}" in
+  --dry-run)     DRY_RUN=true ;;
+  --health-only) HEALTH_ONLY=true ;;
+  --help|-h)     sed -n '2,26p' "$0"; exit 0 ;;
+esac
+
+run() {
+  if $DRY_RUN; then
+    echo "[DRY-RUN] $*"
+  else
+    eval "$@"
+  fi
+}
+
+health_check() {
+  echo "=== health check (4 layers) ==="
+  echo -n "  1. origin direct (HTTP, by Host header):  "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 8 -H "Host: $DOMAIN" "http://45.61.58.125:$PORT/health" || echo FAIL
+  echo -n "  2. CF proxied (HTTPS):                    "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 8 "https://$DOMAIN/" || echo FAIL
+  echo -n "  3. SSL cert valid (openssl):               "
+  echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null || echo FAIL
+  echo -n "  4. www subdomain:                          "
+  curl -sI -o /dev/null -w "%{http_code}\n" -m 8 "https://www.$DOMAIN/" || echo FAIL
+  echo -n "  pm2 status:                                "
+  ssh "$KAMATERA" "pm2 jlist 2>/dev/null | python3 -c 'import sys,json; ps=[p for p in json.load(sys.stdin) if p[\"name\"]==\"'$PM2_NAME'\"]; print(ps[0][\"pm2_env\"][\"status\"] if ps else \"not registered\")'" 2>/dev/null
+}
+
+if $HEALTH_ONLY; then
+  health_check
+  exit 0
+fi
+
+echo "=== checking SSH to Kamatera ==="
+run "ssh -o ConnectTimeout=8 $KAMATERA 'hostname && uptime' || (echo 'SSH failed'; exit 1)"
+
+echo ""
+echo "=== rsync to $REMOTE_DIR ==="
+run "ssh $KAMATERA 'mkdir -p $REMOTE_DIR'"
+run "rsync -az --delete --exclude=node_modules --exclude=.git --exclude='*.log' --exclude='.env' --exclude='logs/' '$LOCAL_DIR/' '$KAMATERA:$REMOTE_DIR/'"
+
+echo ""
+echo "=== npm install --omit=dev ==="
+run "ssh $KAMATERA 'cd $REMOTE_DIR && npm install --omit=dev --no-fund --no-audit'"
+
+echo ""
+echo "=== pm2 ==="
+if $DRY_RUN; then
+  echo "[DRY-RUN] ssh $KAMATERA 'cd $REMOTE_DIR && pm2 startOrReload ecosystem.config.js && pm2 save'"
+else
+  ssh "$KAMATERA" "cd $REMOTE_DIR && pm2 startOrReload ecosystem.config.js && pm2 save"
+fi
+
+echo ""
+sleep 3
+health_check
+
+echo ""
+echo "=== deploy complete ==="
+echo "Site: https://$DOMAIN/"
+echo "pm2:  ssh $KAMATERA 'pm2 logs $PM2_NAME --lines 20'"
diff --git a/deploy/nginx-petitionyour.org.conf b/deploy/nginx-petitionyour.org.conf
new file mode 100644
index 0000000..ff2bbf6
--- /dev/null
+++ b/deploy/nginx-petitionyour.org.conf
@@ -0,0 +1,94 @@
+# petitionyour.org — Kamatera nginx vhost
+#
+# Modeled on the canonical /etc/nginx/sites-available/novasuede.com template.
+#
+# CRITICAL (2026-09-08 fleet HTTPS-000 incident — memory
+# fleet-https-real-cause-ipbind-and-h2-coalescing-trap): the :443 server
+# block MUST bind the public IP explicitly (`listen 45.61.58.125:443 ssl`),
+# NOT the wildcard `listen 443`. A wildcard-only :443 silently misses the
+# box's IP-bound default_server socket and the whole vhost goes HTTPS-000.
+#
+# The `listen [::]:443 ssl` (IPv6) line is commented out by default because
+# novasuede.com's own live vhost has it commented with the note "tailscale
+# holds v6 :443, no public IPv6" — Kamatera's tailscale interface can hold
+# the IPv6 :443 socket, and a second vhost trying to bind it can fail
+# `nginx -t` and freeze reload for the WHOLE box (every other vhost too —
+# see memory kamatera-nginx-canary). Verify with `nginx -t` before enabling.
+# :80 stays wildcard-bound (`listen 80;`) per the deploy smoke test, which
+# curls 127.0.0.1/health by Host header.
+
+# HTTP — ACME challenge + redirect to HTTPS
+server {
+    listen 80;
+    listen 45.61.58.125:80;
+    listen [::]:80;
+    server_name petitionyour.org www.petitionyour.org;
+
+    # Let's Encrypt ACME challenge
+    location /.well-known/acme-challenge/ {
+        root /var/www/html;
+    }
+
+    location / {
+        return 301 https://$host$request_uri;
+    }
+}
+
+# HTTPS — main config
+server {
+    listen 45.61.58.125:443 ssl;      # IP-BOUND — do not change to a bare `listen 443;`
+    # listen [::]:443 ssl;            # commented — verify no tailscale v6:443 conflict before enabling (see note above)
+    server_name petitionyour.org www.petitionyour.org;
+
+    ssl_certificate /etc/letsencrypt/live/petitionyour.org/fullchain.pem;   # managed by Certbot
+    ssl_certificate_key /etc/letsencrypt/live/petitionyour.org/privkey.pem; # managed by Certbot
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_ciphers HIGH:!aNULL:!MD5;
+    ssl_prefer_server_ciphers on;
+
+    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+    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;
+
+    # Cloudflare real-IP restore (DW spec checklist — required on every CF-fronted vhost)
+    set_real_ip_from 173.245.48.0/20;
+    set_real_ip_from 103.21.244.0/22;
+    set_real_ip_from 103.22.200.0/22;
+    set_real_ip_from 103.31.4.0/22;
+    set_real_ip_from 141.101.64.0/18;
+    set_real_ip_from 108.162.192.0/18;
+    set_real_ip_from 190.93.240.0/20;
+    set_real_ip_from 188.114.96.0/20;
+    set_real_ip_from 197.234.240.0/22;
+    set_real_ip_from 198.41.128.0/17;
+    set_real_ip_from 162.158.0.0/15;
+    set_real_ip_from 104.16.0.0/13;
+    set_real_ip_from 104.24.0.0/14;
+    set_real_ip_from 172.64.0.0/13;
+    set_real_ip_from 131.0.72.0/22;
+    set_real_ip_from 2400:cb00::/32;
+    set_real_ip_from 2606:4700::/32;
+    set_real_ip_from 2803:f800::/32;
+    set_real_ip_from 2405:b500::/32;
+    set_real_ip_from 2405:8100::/32;
+    set_real_ip_from 2a06:98c0::/29;
+    set_real_ip_from 2c0f:f248::/32;
+    real_ip_header CF-Connecting-IP;
+
+    location /.well-known/acme-challenge/ {
+        root /var/www/html;
+    }
+
+    location / {
+        proxy_pass http://127.0.0.1:9730;
+        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_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection 'upgrade';
+        proxy_cache_bypass $http_upgrade;
+    }
+}
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..333652e
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,18 @@
+module.exports = {
+  apps: [
+    {
+      name: 'petitionyour-org',
+      script: 'server.js',
+      cwd: '/root/public-projects/petitionyour-org',
+      env: {
+        NODE_ENV: 'production',
+        PORT: 9730,
+      },
+      instances: 1,
+      exec_mode: 'fork',
+      autorestart: true,
+      max_restarts: 20,
+      watch: false,
+    },
+  ],
+};
diff --git a/lib/petitions.js b/lib/petitions.js
new file mode 100644
index 0000000..7086433
--- /dev/null
+++ b/lib/petitions.js
@@ -0,0 +1,155 @@
+const crypto = require('crypto');
+const { readJSON, writeJSON } = require('./store');
+
+const CATEGORIES = [
+  'Local & City',
+  'State',
+  'Federal / Congress',
+  'Environment',
+  'Education',
+  'Healthcare',
+  'Public Safety',
+  'Other',
+];
+
+function slugify(title) {
+  return title
+    .toLowerCase()
+    .trim()
+    .replace(/[^a-z0-9\s-]/g, '')
+    .replace(/\s+/g, '-')
+    .replace(/-+/g, '-')
+    .slice(0, 60)
+    .replace(/^-+|-+$/g, '');
+}
+
+function loadPetitions() {
+  return readJSON('petitions', []);
+}
+function savePetitions(list) {
+  writeJSON('petitions', list);
+}
+function loadSignatures() {
+  return readJSON('signatures', []);
+}
+function saveSignatures(list) {
+  writeJSON('signatures', list);
+}
+
+function signatureCount(petitionId) {
+  return loadSignatures().filter((s) => s.petitionId === petitionId).length;
+}
+
+function withCounts(petition) {
+  return { ...petition, signatureCount: signatureCount(petition.id) };
+}
+
+function listPetitions() {
+  return loadPetitions().map(withCounts);
+}
+
+function getPetitionBySlug(slug) {
+  const p = loadPetitions().find((x) => x.slug === slug);
+  return p ? withCounts(p) : null;
+}
+
+function getPetitionById(id) {
+  const p = loadPetitions().find((x) => x.id === id);
+  return p ? withCounts(p) : null;
+}
+
+function createPetition({ title, description, target, targetType, category, creatorName }) {
+  title = String(title || '').trim();
+  description = String(description || '').trim();
+  target = String(target || '').trim();
+  if (!title || !description || !target) {
+    throw new Error('Title, description, and target are required.');
+  }
+  const petitions = loadPetitions();
+  const base = slugify(title) || 'petition';
+  let slug = base;
+  let n = 1;
+  const existingSlugs = new Set(petitions.map((p) => p.slug));
+  while (existingSlugs.has(slug)) {
+    n += 1;
+    slug = `${base}-${n}`;
+  }
+  const petition = {
+    id: crypto.randomUUID(),
+    slug,
+    title,
+    description,
+    target,
+    targetType: targetType && CATEGORIES ? String(targetType).trim() : '',
+    category: CATEGORIES.includes(category) ? category : 'Other',
+    creatorName: String(creatorName || '').trim(),
+    createdAt: new Date().toISOString(),
+  };
+  petitions.push(petition);
+  savePetitions(petitions);
+  return withCounts(petition);
+}
+
+function addSignature(petitionSlug, { name, email, zip, comment, showNamePublicly, updatesOptIn }) {
+  const petitions = loadPetitions();
+  const petition = petitions.find((p) => p.slug === petitionSlug);
+  if (!petition) throw new Error('Petition not found.');
+
+  name = String(name || '').trim();
+  email = String(email || '').trim().toLowerCase();
+  zip = String(zip || '').trim();
+
+  if (!name) throw new Error('Name is required.');
+  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+    throw new Error('A valid email is required.');
+  }
+  if (zip && !/^\d{5}(-\d{4})?$/.test(zip)) {
+    throw new Error('ZIP must be 5 digits (optionally ZIP+4).');
+  }
+
+  const signatures = loadSignatures();
+  const already = signatures.find(
+    (s) => s.petitionId === petition.id && s.email === email
+  );
+  if (already) {
+    throw new Error('This email has already signed this petition.');
+  }
+
+  const sig = {
+    id: crypto.randomUUID(),
+    petitionId: petition.id,
+    name,
+    email,
+    zip,
+    comment: String(comment || '').trim().slice(0, 500),
+    showNamePublicly: !!showNamePublicly,
+    updatesOptIn: !!updatesOptIn, // consent flag only — no send mechanism wired (Steve-gated)
+    createdAt: new Date().toISOString(),
+  };
+  signatures.push(sig);
+  saveSignatures(signatures);
+  return { signature: sig, petition: withCounts(petition) };
+}
+
+function listSignaturesForPetition(petitionId, { publicOnly = true } = {}) {
+  const sigs = loadSignatures()
+    .filter((s) => s.petitionId === petitionId)
+    .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+  if (!publicOnly) return sigs;
+  return sigs.map((s) => ({
+    ...s,
+    name: s.showNamePublicly ? s.name : 'Anonymous supporter',
+    email: undefined,
+  }));
+}
+
+module.exports = {
+  CATEGORIES,
+  slugify,
+  listPetitions,
+  getPetitionBySlug,
+  getPetitionById,
+  createPetition,
+  addSignature,
+  listSignaturesForPetition,
+};
diff --git a/lib/reps.js b/lib/reps.js
new file mode 100644
index 0000000..9bf3632
--- /dev/null
+++ b/lib/reps.js
@@ -0,0 +1,38 @@
+// $0 / free-only representative lookup.
+//
+// Design decision (honesty over false precision): we resolve ZIP -> state
+// from a bundled, static ZIP3-prefix range table (deterministic, offline,
+// $0 — good enough to say "you're in California"). We deliberately do NOT
+// bundle a static list of senator/representative NAMES, because officeholders
+// change (elections, resignations, appointments, special elections) and a
+// stale hardcoded name list would be silent misinformation on a civic site —
+// worse than no data. Instead we hand back direct links into the official,
+// always-current .gov lookup tools. This is the "link out" option the brief
+// explicitly sanctions, and it's the only path that can never go stale.
+const ranges = require('../data/zip3-state-ranges.json');
+
+function resolveZip(zip) {
+  const clean = String(zip || '').trim();
+  const m = clean.match(/^(\d{5})/);
+  if (!m) return null;
+  const prefix = parseInt(m[1].slice(0, 3), 10);
+  const hit = ranges.find((r) => prefix >= r.start && prefix <= r.end);
+  if (!hit) return null;
+  return {
+    zip: m[1],
+    stateAbbr: hit.state,
+    stateName: hit.name,
+  };
+}
+
+function officialLinks(stateAbbr, zip) {
+  return {
+    findYourHouseRep: 'https://www.house.gov/representatives/find-your-representative',
+    senateContactList: 'https://www.senate.gov/senators/senators-contact.htm',
+    usaGovElectedOfficials: `https://www.usa.gov/elected-officials`,
+    stateGovernmentSite: stateAbbr ? `https://www.usa.gov/state-governments` : null,
+    congressMemberSearch: 'https://www.congress.gov/members',
+  };
+}
+
+module.exports = { resolveZip, officialLinks };
diff --git a/lib/store.js b/lib/store.js
new file mode 100644
index 0000000..533d144
--- /dev/null
+++ b/lib/store.js
@@ -0,0 +1,36 @@
+// Tiny durable JSON-file store. Synchronous by design — MVP scale, and sync
+// fs calls remove any risk of interleaved writes corrupting the file (Node
+// is single-threaded; a sync call can't be pre-empted by another request
+// mid-write). Each write goes to a temp file then renames over the target,
+// so a crash mid-write never leaves a half-written / corrupt JSON file.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+
+function filePath(name) {
+  return path.join(DATA_DIR, `${name}.json`);
+}
+
+function readJSON(name, fallback) {
+  const p = filePath(name);
+  try {
+    const raw = fs.readFileSync(p, 'utf8');
+    return JSON.parse(raw);
+  } catch (err) {
+    if (err.code === 'ENOENT') return fallback;
+    // A corrupt file is a real problem — fail loud rather than silently
+    // returning an empty dataset and letting the corruption get overwritten.
+    console.error(`[store] failed to parse ${p}:`, err.message);
+    throw err;
+  }
+}
+
+function writeJSON(name, data) {
+  const p = filePath(name);
+  const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
+  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
+  fs.renameSync(tmp, p);
+}
+
+module.exports = { readJSON, writeJSON, DATA_DIR };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7c34d1d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,928 @@
+{
+  "name": "petitionyour",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "petitionyour",
+      "version": "0.1.0",
+      "dependencies": {
+        "ejs": "^3.1.10",
+        "express": "^4.21.2"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "license": "MIT"
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.8",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+      "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.16.0",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.16.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+      "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+      "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/ejs": {
+      "version": "3.1.10",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jake": "^10.8.5"
+      },
+      "bin": {
+        "ejs": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/filelist": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+      "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/jake": {
+      "version": "10.9.4",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "async": "^3.2.6",
+        "filelist": "^1.0.4",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "jake": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+      "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..02aa23b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "petitionyour",
+  "version": "0.1.0",
+  "description": "Petition Your Representatives — a civic petition tool (petitionyour.org)",
+  "private": true,
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "PORT=4000 node server.js"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "dependencies": {
+    "ejs": "^3.1.10",
+    "express": "^4.21.2"
+  }
+}
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..edef0c0
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,224 @@
+/* Petition Your Representatives — civic aesthetic. Trustworthy, not flashy. */
+
+:root {
+  --bg: #f7f5ef;
+  --ink: #16233b;
+  --ink-soft: #45536b;
+  --ink-faint: #7c879a;
+  --line: rgba(22, 35, 59, 0.14);
+  --card-bg: #ffffff;
+  --header-bg: #ffffff;
+
+  /* accents — stay constant across light/dark per standing rule */
+  --navy: #1c3f6e;
+  --navy-dark: #12294c;
+  --red: #9c2b2b;
+  --gold: #a9791f;
+  --success-bg: #e6f2ea;
+  --success-ink: #1f6f3e;
+  --error-bg: #fbe9e9;
+  --error-ink: #9c2b2b;
+
+  --card-min: 320px;
+  --radius: 10px;
+}
+
+[data-theme="dark"] {
+  --bg: #0d1420;
+  --ink: #eef1f6;
+  --ink-soft: #b7c0cf;
+  --ink-faint: #7c879a;
+  --line: rgba(238, 241, 246, 0.16);
+  --card-bg: #131c2b;
+  --header-bg: #0f1826;
+  --success-bg: #10281a;
+  --success-ink: #6fcf97;
+  --error-bg: #2c1414;
+  --error-ink: #e18888;
+}
+
+html { background: var(--bg); color-scheme: light dark; }
+html[data-theme="dark"] { color-scheme: dark; }
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+
+body {
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+  background: var(--bg);
+  color: var(--ink);
+  line-height: 1.55;
+  font-size: 16px;
+}
+
+h1, h2, h3 {
+  font-family: Georgia, "Times New Roman", serif;
+  color: var(--ink);
+  line-height: 1.2;
+  margin: 0 0 0.5em;
+}
+h1 { font-size: clamp(1.7rem, 3vw, 2.5rem); }
+h2 { font-size: 1.4rem; margin-top: 2rem; }
+
+a { color: var(--navy); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+.wrap { max-width: 1160px; margin: 0 auto; padding: 0 20px; }
+.narrow { max-width: 720px; }
+
+.skip-link {
+  position: absolute; left: -999px; top: 0; background: var(--navy); color: #fff;
+  padding: 8px 14px; z-index: 100;
+}
+.skip-link:focus { left: 10px; top: 10px; }
+
+/* header */
+.site-header {
+  background: var(--header-bg);
+  border-bottom: 1px solid var(--line);
+  position: sticky; top: 0; z-index: 20;
+}
+.header-inner { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; }
+.brand {
+  font-family: Georgia, "Times New Roman", serif;
+  font-size: 1.3rem; font-weight: 700; color: var(--ink);
+  display: flex; align-items: center; gap: 8px;
+}
+.brand:hover { text-decoration: none; }
+.brand-mark { color: var(--navy); }
+.brand-accent { color: var(--red); }
+.site-nav { display: flex; align-items: center; gap: 20px; }
+.site-nav a { color: var(--ink-soft); font-weight: 600; font-size: 0.95rem; }
+.site-nav a:hover { color: var(--navy); text-decoration: none; }
+
+.theme-toggle {
+  background: none; border: 1px solid var(--line); border-radius: 999px;
+  width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
+  cursor: pointer; color: var(--ink-soft);
+}
+.theme-toggle:hover { border-color: var(--navy); color: var(--navy); }
+.theme-toggle .icon-moon { display: none; }
+[data-theme="dark"] .theme-toggle .icon-sun { display: none; }
+[data-theme="dark"] .theme-toggle .icon-moon { display: block; }
+
+/* hero */
+.hero { background: linear-gradient(180deg, var(--card-bg), var(--bg)); border-bottom: 1px solid var(--line); padding: 3.5rem 0; }
+.hero h1 { max-width: 760px; }
+.hero h1 em { font-style: italic; color: var(--navy); }
+.hero-sub { color: var(--ink-soft); max-width: 620px; font-size: 1.05rem; }
+.hero-actions { display: flex; gap: 12px; margin: 1.5rem 0; flex-wrap: wrap; }
+.hero-stat { color: var(--ink-faint); font-size: 0.9rem; }
+.hero-stat strong { color: var(--ink); }
+
+/* buttons */
+.btn {
+  display: inline-block; padding: 10px 20px; border-radius: 6px; font-weight: 700;
+  font-size: 0.95rem; cursor: pointer; border: 1px solid transparent; text-align: center;
+}
+.btn:hover { text-decoration: none; }
+.btn-primary { background: var(--navy); color: #fff; }
+.btn-primary:hover { background: var(--navy-dark); }
+.btn-ghost { background: transparent; color: var(--navy); border-color: var(--navy); }
+.btn-ghost:hover { background: var(--navy); color: #fff; }
+.btn-sm { padding: 6px 14px; font-size: 0.85rem; }
+.btn-wide { width: 100%; padding: 13px 20px; font-size: 1rem; }
+
+/* grid controls (sort + density, standing rule) */
+.grid-controls {
+  display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end;
+  padding: 20px 0; border-bottom: 1px solid var(--line); margin-bottom: 20px;
+}
+.control { display: flex; flex-direction: column; gap: 4px; min-width: 140px; }
+.control label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-faint); font-weight: 700; }
+.control input, .control select {
+  padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px;
+  background: var(--card-bg); color: var(--ink); font-size: 0.9rem;
+}
+.density-control { min-width: 160px; }
+.density-control input[type="range"] { width: 100%; accent-color: var(--navy); }
+
+/* petition grid */
+.petition-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(var(--card-min), 1fr));
+  gap: 20px;
+}
+.petition-card {
+  display: block; background: var(--card-bg); border: 1px solid var(--line); border-radius: var(--radius);
+  padding: 20px; color: var(--ink);
+}
+.petition-card:hover { border-color: var(--navy); text-decoration: none; }
+.card-category {
+  display: inline-block; background: rgba(28, 63, 110, 0.10); color: var(--navy);
+  font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
+  padding: 3px 9px; border-radius: 999px; margin-bottom: 10px;
+}
+[data-theme="dark"] .card-category { background: rgba(28, 63, 110, 0.35); }
+.petition-card h2 { font-size: 1.15rem; margin: 0 0 6px; }
+.card-target { color: var(--ink-soft); font-size: 0.85rem; margin: 0 0 8px; }
+.card-desc { color: var(--ink-soft); font-size: 0.92rem; margin: 0 0 14px; }
+.card-meta { display: flex; justify-content: space-between; align-items: center; font-size: 0.82rem; color: var(--ink-faint); }
+.sig-count strong { color: var(--red); font-size: 1rem; }
+
+.empty-state, .no-results { text-align: center; color: var(--ink-faint); padding: 3rem 0; }
+
+/* forms */
+.stack-form, .inline-form { display: flex; flex-direction: column; gap: 6px; margin-top: 1.2rem; }
+.inline-form { flex-direction: row; gap: 10px; align-items: center; max-width: 420px; }
+.inline-form input { flex: 1; }
+.stack-form label { font-weight: 700; font-size: 0.9rem; margin-top: 10px; }
+.stack-form input, .stack-form textarea, .stack-form select, .inline-form input {
+  padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px;
+  background: var(--card-bg); color: var(--ink); font-size: 1rem; font-family: inherit;
+}
+.field-hint { color: var(--ink-faint); font-size: 0.8rem; margin: 2px 0 0; }
+.checkbox-row { display: flex; align-items: center; gap: 8px; font-weight: 400; font-size: 0.92rem; }
+.fine-print { color: var(--ink-faint); font-size: 0.8rem; margin-top: 10px; }
+
+.lede { color: var(--ink-soft); font-size: 1.05rem; }
+
+/* alerts */
+.alert { padding: 12px 16px; border-radius: 8px; margin: 1rem 0; font-size: 0.92rem; }
+.alert-success { background: var(--success-bg); color: var(--success-ink); }
+.alert-error { background: var(--error-bg); color: var(--error-ink); }
+
+/* petition detail */
+.breadcrumb { font-size: 0.85rem; margin-top: 1.5rem; }
+.petition-detail h1 { margin-top: 10px; }
+.signature-tally {
+  display: inline-block; background: rgba(156, 43, 43, 0.08); color: var(--red);
+  font-weight: 700; padding: 8px 16px; border-radius: 999px; margin: 1rem 0;
+}
+[data-theme="dark"] .signature-tally { background: rgba(156, 43, 43, 0.22); }
+.petition-body { font-size: 1.02rem; color: var(--ink); margin: 1.2rem 0; white-space: pre-wrap; }
+.share-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 1.5rem 0; }
+.share-label { font-size: 0.85rem; color: var(--ink-faint); font-weight: 700; }
+
+.sign-panel {
+  background: var(--card-bg); border: 1px solid var(--line); border-radius: var(--radius);
+  padding: 24px; margin: 2rem 0;
+}
+
+.supporters { margin: 2rem 0; }
+.supporter-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; }
+.supporter-list li { border-bottom: 1px solid var(--line); padding-bottom: 10px; }
+.supporter-zip { color: var(--ink-faint); font-size: 0.82rem; margin-left: 8px; }
+.supporter-comment { color: var(--ink-soft); font-size: 0.9rem; margin: 4px 0 0; }
+
+.reps-widget { background: rgba(28, 63, 110, 0.06); border-radius: var(--radius); padding: 20px; margin: 2rem 0; }
+[data-theme="dark"] .reps-widget { background: rgba(28, 63, 110, 0.18); }
+.reps-result-block { margin-top: 1rem; }
+.reps-links { padding-left: 20px; }
+.reps-links li { margin-bottom: 6px; }
+.reps-state { font-size: 1.05rem; }
+.coverage-note { color: var(--ink-faint); font-size: 0.82rem; margin-top: 2rem; }
+
+/* footer */
+.site-footer { border-top: 1px solid var(--line); margin-top: 3rem; padding: 2rem 0; color: var(--ink-faint); font-size: 0.85rem; }
+.footer-inner p { margin: 0 0 10px; }
+
+@media (max-width: 640px) {
+  .header-inner { flex-wrap: wrap; gap: 10px; }
+  .site-nav { gap: 12px; font-size: 0.85rem; }
+  .petition-grid { grid-template-columns: 1fr; }
+  .inline-form { flex-direction: column; align-items: stretch; }
+}
diff --git a/public/js/site.js b/public/js/site.js
new file mode 100644
index 0000000..d69dd4b
--- /dev/null
+++ b/public/js/site.js
@@ -0,0 +1,129 @@
+(function () {
+  'use strict';
+  var LS_PREFIX = 'petitionyour:';
+
+  // ---- Browse grid: sort + density + search + category (localStorage-persisted) ----
+  var grid = document.getElementById('petition-grid');
+  if (grid) {
+    var sortSelect = document.getElementById('sort-select');
+    var densitySlider = document.getElementById('density-slider');
+    var searchInput = document.getElementById('q');
+    var categorySelect = document.getElementById('category-filter');
+    var noResults = document.getElementById('no-results');
+    var cards = Array.prototype.slice.call(grid.querySelectorAll('.petition-card'));
+
+    var densityMinWidth = { 1: 420, 2: 340, 3: 280, 4: 220 };
+
+    function applyDensity(val) {
+      grid.style.setProperty('--card-min', (densityMinWidth[val] || 280) + 'px');
+      grid.dataset.density = val;
+      try { localStorage.setItem(LS_PREFIX + 'density', val); } catch (e) {}
+    }
+
+    function applySort(mode) {
+      var sorted = cards.slice().sort(function (a, b) {
+        switch (mode) {
+          case 'most-signed':
+            return Number(b.dataset.signatures) - Number(a.dataset.signatures);
+          case 'title-az':
+            return a.dataset.title.localeCompare(b.dataset.title);
+          case 'target-az':
+            return a.dataset.target.localeCompare(b.dataset.target);
+          case 'newest':
+          default:
+            return new Date(b.dataset.created) - new Date(a.dataset.created);
+        }
+      });
+      sorted.forEach(function (card) { grid.appendChild(card); });
+      try { localStorage.setItem(LS_PREFIX + 'sort', mode); } catch (e) {}
+    }
+
+    function applyFilter() {
+      var q = (searchInput && searchInput.value || '').trim().toLowerCase();
+      var cat = (categorySelect && categorySelect.value) || '';
+      var visibleCount = 0;
+      cards.forEach(function (card) {
+        var matchesQ = !q ||
+          card.dataset.title.indexOf(q) !== -1 ||
+          card.dataset.target.indexOf(q) !== -1 ||
+          card.dataset.desc.indexOf(q) !== -1;
+        var matchesCat = !cat || card.dataset.category === cat;
+        var visible = matchesQ && matchesCat;
+        card.hidden = !visible;
+        if (visible) visibleCount += 1;
+      });
+      if (noResults) noResults.hidden = visibleCount !== 0;
+    }
+
+    // Restore persisted state
+    var savedSort, savedDensity;
+    try {
+      savedSort = localStorage.getItem(LS_PREFIX + 'sort');
+      savedDensity = localStorage.getItem(LS_PREFIX + 'density');
+    } catch (e) {}
+
+    if (sortSelect) {
+      sortSelect.value = savedSort || 'newest';
+      applySort(sortSelect.value);
+      sortSelect.addEventListener('change', function () { applySort(sortSelect.value); });
+    }
+    if (densitySlider) {
+      densitySlider.value = savedDensity || '3';
+      applyDensity(densitySlider.value);
+      densitySlider.addEventListener('input', function () { applyDensity(densitySlider.value); });
+    }
+    if (searchInput) searchInput.addEventListener('input', applyFilter);
+    if (categorySelect) categorySelect.addEventListener('change', applyFilter);
+  }
+
+  // ---- Copy link button ----
+  var copyBtn = document.getElementById('copy-link-btn');
+  if (copyBtn) {
+    copyBtn.addEventListener('click', function () {
+      var url = copyBtn.dataset.url;
+      var done = function () {
+        var original = copyBtn.textContent;
+        copyBtn.textContent = 'Copied!';
+        setTimeout(function () { copyBtn.textContent = original; }, 1800);
+      };
+      if (navigator.clipboard && navigator.clipboard.writeText) {
+        navigator.clipboard.writeText(url).then(done, function () { window.prompt('Copy this link:', url); });
+      } else {
+        window.prompt('Copy this link:', url);
+      }
+    });
+  }
+
+  // ---- Inline find-your-reps lookup on the petition detail page ----
+  var repsForm = document.getElementById('reps-lookup-form');
+  if (repsForm) {
+    var repsZip = document.getElementById('reps-zip');
+    var repsResult = document.getElementById('reps-result');
+    repsForm.addEventListener('submit', function (evt) {
+      evt.preventDefault();
+      var zip = (repsZip.value || '').trim();
+      if (!zip) return;
+      repsResult.innerHTML = '<p class="field-hint">Looking up…</p>';
+      fetch('/api/find-reps?zip=' + encodeURIComponent(zip))
+        .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, data: data }; }); })
+        .then(function (res) {
+          if (!res.ok || !res.data.ok) {
+            repsResult.innerHTML = '<div class="alert alert-error">' + (res.data.error || 'Could not resolve that ZIP.') + '</div>';
+            return;
+          }
+          var d = res.data;
+          repsResult.innerHTML =
+            '<div class="reps-result-block">' +
+            '<p class="reps-state">ZIP <strong>' + d.zip + '</strong> is in <strong>' + d.stateName + '</strong> (' + d.stateAbbr + ').</p>' +
+            '<ul class="reps-links">' +
+            '<li><a href="' + d.links.findYourHouseRep + '" target="_blank" rel="noopener noreferrer">Find your U.S. House Representative</a></li>' +
+            '<li><a href="' + d.links.senateContactList + '" target="_blank" rel="noopener noreferrer">Browse your U.S. Senators</a></li>' +
+            '<li><a href="' + d.links.congressMemberSearch + '" target="_blank" rel="noopener noreferrer">Search current Congress members</a></li>' +
+            '</ul></div>';
+        })
+        .catch(function () {
+          repsResult.innerHTML = '<div class="alert alert-error">Lookup failed — please try again.</div>';
+        });
+    });
+  }
+})();
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..ed16b2d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,127 @@
+const express = require('express');
+const path = require('path');
+const petitions = require('./lib/petitions');
+const reps = require('./lib/reps');
+
+const app = express();
+const PORT = process.env.PORT || 4000;
+
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, 'views'));
+app.set('trust proxy', true); // behind Cloudflare + nginx in prod
+
+app.use(express.urlencoded({ extended: false }));
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'public')));
+
+// Minimal, honest security headers — no framework dependency needed for this.
+app.use((req, res, next) => {
+  res.setHeader('X-Content-Type-Options', 'nosniff');
+  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
+  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+  next();
+});
+
+function siteUrl(req) {
+  const proto = req.headers['x-forwarded-proto'] || req.protocol;
+  return `${proto}://${req.get('host')}`;
+}
+
+// ---- health (smoke test target) ----
+app.get('/health', (req, res) => {
+  res.status(200).type('text/plain').send('ok');
+});
+
+// ---- browse petitions ----
+app.get('/', (req, res) => {
+  const list = petitions.listPetitions().sort(
+    (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
+  );
+  const totalSignatures = list.reduce((sum, p) => sum + p.signatureCount, 0);
+  res.render('index', {
+    petitions: list,
+    categories: petitions.CATEGORIES,
+    totalSignatures,
+    siteUrl: siteUrl(req),
+  });
+});
+
+// ---- create petition ----
+app.get('/petitions/new', (req, res) => {
+  res.render('new', { categories: petitions.CATEGORIES, error: null, form: {} });
+});
+
+app.post('/petitions', (req, res) => {
+  try {
+    const p = petitions.createPetition(req.body);
+    res.redirect(`/petitions/${p.slug}?created=1`);
+  } catch (err) {
+    res.status(400).render('new', {
+      categories: petitions.CATEGORIES,
+      error: err.message,
+      form: req.body,
+    });
+  }
+});
+
+// ---- petition detail + sign ----
+app.get('/petitions/:slug', (req, res) => {
+  const p = petitions.getPetitionBySlug(req.params.slug);
+  if (!p) return res.status(404).render('404', {});
+  const supporters = petitions.listSignaturesForPetition(p.id, { publicOnly: true }).slice(0, 25);
+  res.render('petition', {
+    p,
+    supporters,
+    siteUrl: siteUrl(req),
+    created: req.query.created === '1',
+    signed: req.query.signed === '1',
+    error: null,
+    form: {},
+  });
+});
+
+app.post('/petitions/:slug/sign', (req, res) => {
+  const p = petitions.getPetitionBySlug(req.params.slug);
+  if (!p) return res.status(404).render('404', {});
+  try {
+    petitions.addSignature(req.params.slug, req.body);
+    res.redirect(`/petitions/${req.params.slug}?signed=1#top`);
+  } catch (err) {
+    const supporters = petitions.listSignaturesForPetition(p.id, { publicOnly: true }).slice(0, 25);
+    res.status(400).render('petition', {
+      p,
+      supporters,
+      siteUrl: siteUrl(req),
+      created: false,
+      signed: false,
+      error: err.message,
+      form: req.body,
+    });
+  }
+});
+
+// ---- find your representatives (ZIP -> state -> official .gov links) ----
+app.get('/find-reps', (req, res) => {
+  const zip = req.query.zip || '';
+  const hit = zip ? reps.resolveZip(zip) : null;
+  const links = hit ? reps.officialLinks(hit.stateAbbr, hit.zip) : null;
+  res.render('find-reps', { zip, hit, links, error: zip && !hit ? 'Could not match that ZIP code.' : null });
+});
+
+app.get('/api/find-reps', (req, res) => {
+  const zip = req.query.zip || '';
+  const hit = reps.resolveZip(zip);
+  if (!hit) {
+    return res.status(404).json({ ok: false, error: 'Could not match that ZIP code to a state.' });
+  }
+  res.json({ ok: true, ...hit, links: reps.officialLinks(hit.stateAbbr, hit.zip) });
+});
+
+// ---- 404 ----
+app.use((req, res) => {
+  res.status(404).render('404', {});
+});
+
+app.listen(PORT, () => {
+  console.log(`petitionyour listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/views/404.ejs b/views/404.ejs
new file mode 100644
index 0000000..4b1a621
--- /dev/null
+++ b/views/404.ejs
@@ -0,0 +1,8 @@
+<%- include('partials/head', { title: 'Not Found', description: 'Page not found.' }) %>
+
+<section class="wrap narrow" style="text-align:center; padding: 4rem 0;">
+  <h1>Page not found</h1>
+  <p><a href="/">&larr; Back to all petitions</a></p>
+</section>
+
+<%- include('partials/foot') %>
diff --git a/views/find-reps.ejs b/views/find-reps.ejs
new file mode 100644
index 0000000..81e07bc
--- /dev/null
+++ b/views/find-reps.ejs
@@ -0,0 +1,32 @@
+<%- include('partials/head', { title: 'Find Your Representatives', description: 'Look up your state by ZIP code and jump straight to official .gov tools to find your U.S. Senators, House Representative, and state officials.' }) %>
+
+<section class="wrap narrow">
+  <h1>Find Your Representatives</h1>
+  <p class="lede">We resolve your ZIP code to a state instantly and for free — then hand you straight to the official, always-current government lookup tools. We deliberately don't keep our own list of officeholder names, because elections and appointments change them and a stale list would be worse than none.</p>
+
+  <form method="GET" action="/find-reps" class="inline-form">
+    <input type="text" name="zip" placeholder="ZIP code" maxlength="10" value="<%= zip || '' %>">
+    <button type="submit" class="btn btn-primary btn-sm">Look up</button>
+  </form>
+
+  <% if (error) { %>
+    <div class="alert alert-error"><%= error %></div>
+  <% } %>
+
+  <% if (hit) { %>
+    <div class="reps-result-block">
+      <p class="reps-state">ZIP <strong><%= hit.zip %></strong> is in <strong><%= hit.stateName %></strong> (<%= hit.stateAbbr %>).</p>
+      <ul class="reps-links">
+        <li><a href="<%= links.findYourHouseRep %>" target="_blank" rel="noopener noreferrer">Find your U.S. House Representative (official House.gov ZIP lookup)</a></li>
+        <li><a href="<%= links.senateContactList %>" target="_blank" rel="noopener noreferrer">Browse your two U.S. Senators (official Senate.gov contact list)</a></li>
+        <li><a href="<%= links.congressMemberSearch %>" target="_blank" rel="noopener noreferrer">Search current Congress members (Congress.gov)</a></li>
+        <li><a href="<%= links.stateGovernmentSite %>" target="_blank" rel="noopener noreferrer">Find your state government &amp; legislature (USA.gov)</a></li>
+        <li><a href="<%= links.usaGovElectedOfficials %>" target="_blank" rel="noopener noreferrer">All elected officials by address (USA.gov)</a></li>
+      </ul>
+    </div>
+  <% } %>
+
+  <p class="coverage-note">Coverage note: ZIP-to-state resolution uses a bundled USPS ZIP-prefix range table and covers the 50 states, DC, and major territories. A handful of edge-case or newly issued ZIP codes may not resolve — if yours doesn't, use the official lookups above directly.</p>
+</section>
+
+<%- include('partials/foot') %>
diff --git a/views/index.ejs b/views/index.ejs
new file mode 100644
index 0000000..85ed310
--- /dev/null
+++ b/views/index.ejs
@@ -0,0 +1,75 @@
+<%- include('partials/head', { title: '', description: 'Browse active petitions, start your own, and find your elected representatives — free and transparent.' }) %>
+
+<section class="hero">
+  <div class="wrap">
+    <h1>Make your voice heard — <em>and get it in front of the right official.</em></h1>
+    <p class="hero-sub">Start a petition, gather real signatures, and find exactly who represents you at the local, state, and federal level. No paywalls, no dark patterns.</p>
+    <div class="hero-actions">
+      <a href="/petitions/new" class="btn btn-primary">Start a Petition</a>
+      <a href="/find-reps" class="btn btn-ghost">Find Your Representatives</a>
+    </div>
+    <p class="hero-stat"><strong><%= petitions.length %></strong> active petition<%= petitions.length === 1 ? '' : 's' %> &middot; <strong><%= totalSignatures %></strong> signature<%= totalSignatures === 1 ? '' : 's' %> collected</p>
+  </div>
+</section>
+
+<section class="wrap">
+  <div class="grid-controls" role="group" aria-label="Sort and display controls">
+    <div class="control">
+      <label for="q">Search</label>
+      <input type="search" id="q" placeholder="Search title, target, description…" autocomplete="off">
+    </div>
+    <div class="control">
+      <label for="category-filter">Category</label>
+      <select id="category-filter">
+        <option value="">All categories</option>
+        <% categories.forEach(function(c){ %>
+          <option value="<%= c %>"><%= c %></option>
+        <% }) %>
+      </select>
+    </div>
+    <div class="control">
+      <label for="sort-select">Sort</label>
+      <select id="sort-select">
+        <option value="newest">Newest</option>
+        <option value="most-signed">Most Signatures</option>
+        <option value="title-az">Title A→Z</option>
+        <option value="target-az">Target A→Z</option>
+      </select>
+    </div>
+    <div class="control density-control">
+      <label for="density-slider">Density</label>
+      <input type="range" id="density-slider" min="1" max="4" step="1" value="3">
+    </div>
+  </div>
+
+  <% if (petitions.length === 0) { %>
+    <div class="empty-state">
+      <p>No petitions yet. <a href="/petitions/new">Be the first to start one.</a></p>
+    </div>
+  <% } else { %>
+    <div class="petition-grid" id="petition-grid" data-density="3">
+      <% petitions.forEach(function(p){ %>
+        <a class="petition-card"
+           href="/petitions/<%= p.slug %>"
+           data-title="<%= p.title.toLowerCase() %>"
+           data-target="<%= p.target.toLowerCase() %>"
+           data-desc="<%= p.description.toLowerCase() %>"
+           data-category="<%= p.category %>"
+           data-created="<%= p.createdAt %>"
+           data-signatures="<%= p.signatureCount %>">
+          <span class="card-category"><%= p.category %></span>
+          <h2><%= p.title %></h2>
+          <p class="card-target">Target: <%= p.target %></p>
+          <p class="card-desc"><%= p.description.slice(0, 140) %><%= p.description.length > 140 ? '…' : '' %></p>
+          <div class="card-meta">
+            <span class="sig-count"><strong><%= p.signatureCount %></strong> signature<%= p.signatureCount === 1 ? '' : 's' %></span>
+            <span class="card-date"><%= new Date(p.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) %></span>
+          </div>
+        </a>
+      <% }) %>
+    </div>
+    <p class="no-results" id="no-results" hidden>No petitions match your search.</p>
+  <% } %>
+</section>
+
+<%- include('partials/foot') %>
diff --git a/views/new.ejs b/views/new.ejs
new file mode 100644
index 0000000..2724999
--- /dev/null
+++ b/views/new.ejs
@@ -0,0 +1,45 @@
+<%- include('partials/head', { title: 'Start a Petition', description: 'Start a free petition and rally support for a cause aimed at your local, state, or federal representatives.' }) %>
+
+<section class="wrap narrow">
+  <h1>Start a Petition</h1>
+  <p class="lede">Be specific about who you're asking and what you want them to do — petitions with a clear target and a clear ask get taken seriously.</p>
+
+  <% if (error) { %>
+    <div class="alert alert-error"><%= error %></div>
+  <% } %>
+
+  <form method="POST" action="/petitions" class="stack-form">
+    <label for="title">Petition title *</label>
+    <input type="text" id="title" name="title" required maxlength="140" placeholder="e.g. Repave Elm Street Before Winter" value="<%= form.title || '' %>">
+
+    <label for="description">Description *</label>
+    <textarea id="description" name="description" required rows="6" placeholder="Explain the issue, why it matters, and exactly what you're asking for."><%= form.description || '' %></textarea>
+
+    <label for="target">Who is this addressed to? *</label>
+    <input type="text" id="target" name="target" required maxlength="140" placeholder="e.g. City Council, State Senator Jane Doe, U.S. House of Representatives" value="<%= form.target || '' %>">
+
+    <label for="targetType">Level of government (optional)</label>
+    <select id="targetType" name="targetType">
+      <option value="">Not specified</option>
+      <option value="City Council">City / Local</option>
+      <option value="State Legislature">State</option>
+      <option value="Federal / Congress">Federal / Congress</option>
+      <option value="Other">Other / Organization</option>
+    </select>
+
+    <label for="category">Category</label>
+    <select id="category" name="category">
+      <% categories.forEach(function(c){ %>
+        <option value="<%= c %>" <%= form.category === c ? 'selected' : '' %>><%= c %></option>
+      <% }) %>
+    </select>
+
+    <label for="creatorName">Your name or organization (optional, shown publicly as the starter)</label>
+    <input type="text" id="creatorName" name="creatorName" maxlength="80" placeholder="e.g. Jane D. or River Basin Watch" value="<%= form.creatorName || '' %>">
+
+    <button type="submit" class="btn btn-primary btn-wide">Publish Petition</button>
+    <p class="fine-print">Your petition will be public immediately. There's no review queue — please keep it accurate and civil.</p>
+  </form>
+</section>
+
+<%- include('partials/foot') %>
diff --git a/views/partials/foot.ejs b/views/partials/foot.ejs
new file mode 100644
index 0000000..1cf9d80
--- /dev/null
+++ b/views/partials/foot.ejs
@@ -0,0 +1,18 @@
+</main>
+<footer class="site-footer">
+  <div class="wrap footer-inner">
+    <p><strong>Petition Your Representatives</strong> is a free civic tool for organizing petitions and finding your elected officials. We link out to official <a href="https://www.usa.gov/elected-officials" target="_blank" rel="noopener noreferrer">.gov</a> sources for current officeholders rather than maintaining our own list — so the names you see are always accurate, never stale.</p>
+    <p class="fine-print">By signing a petition you agree we may display your name (if you choose to show it) and store your email to verify your signature. We never sell your information and we do not send bulk email without your explicit opt-in.</p>
+  </div>
+</footer>
+<script>
+  (function(){
+    var btn=document.getElementById('theme-toggle');if(!btn)return;
+    function sync(){var t=document.documentElement.getAttribute('data-theme');btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
+    sync();
+    btn.addEventListener('click',function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var nxt=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',nxt);try{localStorage.setItem('theme',nxt);}catch(e){}sync();});
+  })();
+</script>
+<script src="/js/site.js"></script>
+</body>
+</html>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
new file mode 100644
index 0000000..da2a0ee
--- /dev/null
+++ b/views/partials/head.ejs
@@ -0,0 +1,34 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <script>
+    (function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();
+  </script>
+  <title><%= typeof title !== 'undefined' && title ? title + ' — Petition Your Representatives' : 'Petition Your Representatives — petitionyour.org' %></title>
+  <meta name="description" content="<%= typeof description !== 'undefined' && description ? description : 'Start a petition, gather real signatures, and find your elected representatives — free, transparent, and built for civic action.' %>">
+  <meta name="theme-color" content="#f7f5ef" media="(prefers-color-scheme: light)">
+  <meta name="theme-color" content="#0d1420" media="(prefers-color-scheme: dark)">
+  <link rel="stylesheet" href="/css/site.css">
+</head>
+<body>
+<a href="#main" class="skip-link">Skip to content</a>
+<header class="site-header" id="top">
+  <div class="wrap header-inner">
+    <a href="/" class="brand">
+      <span class="brand-mark" aria-hidden="true">&#9776;</span>
+      Petition<span class="brand-accent">Your</span>
+    </a>
+    <nav class="site-nav">
+      <a href="/">Browse</a>
+      <a href="/petitions/new">Start a Petition</a>
+      <a href="/find-reps">Find Your Reps</a>
+      <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false" title="Toggle dark / light">
+        <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
+        <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
+      </button>
+    </nav>
+  </div>
+</header>
+<main id="main">
diff --git a/views/petition.ejs b/views/petition.ejs
new file mode 100644
index 0000000..3ba94b9
--- /dev/null
+++ b/views/petition.ejs
@@ -0,0 +1,102 @@
+<%
+  const shareUrl = siteUrl + '/petitions/' + p.slug;
+  const shareText = 'Sign this petition: ' + p.title;
+%>
+<%- include('partials/head', { title: p.title, description: p.description.slice(0, 155) }) %>
+
+<section class="wrap narrow petition-detail">
+  <p class="breadcrumb"><a href="/">&larr; All petitions</a></p>
+
+  <% if (created) { %>
+    <div class="alert alert-success">Your petition is live. Share the link below to start gathering signatures.</div>
+  <% } %>
+  <% if (signed) { %>
+    <div class="alert alert-success">Thanks for signing — your voice has been added.</div>
+  <% } %>
+  <% if (error) { %>
+    <div class="alert alert-error"><%= error %></div>
+  <% } %>
+
+  <span class="card-category"><%= p.category %></span>
+  <h1><%= p.title %></h1>
+  <p class="card-target">Addressed to: <strong><%= p.target %></strong></p>
+  <p class="card-date">Started <%= new Date(p.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) %><%= p.creatorName ? ' by ' + p.creatorName : '' %></p>
+
+  <div class="signature-tally">
+    <strong id="sig-tally-count"><%= p.signatureCount %></strong> signature<%= p.signatureCount === 1 ? '' : 's' %> so far
+  </div>
+
+  <div class="petition-body">
+    <p><%= p.description %></p>
+  </div>
+
+  <div class="share-row" aria-label="Share this petition">
+    <span class="share-label">Share:</span>
+    <button type="button" class="btn btn-ghost btn-sm" id="copy-link-btn" data-url="<%= shareUrl %>">Copy Link</button>
+    <a class="btn btn-ghost btn-sm" target="_blank" rel="noopener noreferrer"
+       href="https://twitter.com/intent/tweet?text=<%= encodeURIComponent(shareText) %>&url=<%= encodeURIComponent(shareUrl) %>">Share on X</a>
+    <a class="btn btn-ghost btn-sm" target="_blank" rel="noopener noreferrer"
+       href="https://www.facebook.com/sharer/sharer.php?u=<%= encodeURIComponent(shareUrl) %>">Share on Facebook</a>
+    <a class="btn btn-ghost btn-sm"
+       href="mailto:?subject=<%= encodeURIComponent(shareText) %>&body=<%= encodeURIComponent(shareText + ' ' + shareUrl) %>">Email</a>
+  </div>
+
+  <div class="sign-panel" id="sign">
+    <h2>Sign this petition</h2>
+    <form method="POST" action="/petitions/<%= p.slug %>/sign" class="stack-form">
+      <label for="name">Full name *</label>
+      <input type="text" id="name" name="name" required maxlength="100" value="<%= form.name || '' %>">
+
+      <label for="email">Email *</label>
+      <input type="email" id="email" name="email" required maxlength="160" value="<%= form.email || '' %>">
+      <p class="field-hint">Used to verify your signature and prevent duplicates. Never shown publicly, never sold.</p>
+
+      <label for="zip">ZIP code (optional)</label>
+      <input type="text" id="zip" name="zip" maxlength="10" placeholder="e.g. 90210" value="<%= form.zip || '' %>">
+      <p class="field-hint">Helps show which districts support this — also lets you look up your representatives.</p>
+
+      <label for="comment">Why does this matter to you? (optional)</label>
+      <textarea id="comment" name="comment" rows="3" maxlength="500"><%= form.comment || '' %></textarea>
+
+      <label class="checkbox-row">
+        <input type="checkbox" name="showNamePublicly" value="1" checked>
+        Show my name publicly on this petition (uncheck to sign anonymously)
+      </label>
+
+      <label class="checkbox-row">
+        <input type="checkbox" name="updatesOptIn" value="1">
+        Email me updates about this petition (optional — we will not email you unless you check this)
+      </label>
+
+      <button type="submit" class="btn btn-primary btn-wide">Sign This Petition</button>
+      <p class="fine-print">By signing, you agree to our handling of your info as described in the footer below. We never send bulk email without this opt-in checked, and every update email includes an unsubscribe link.</p>
+    </form>
+  </div>
+
+  <% if (supporters.length) { %>
+    <div class="supporters">
+      <h2>Recent supporters</h2>
+      <ul class="supporter-list">
+        <% supporters.forEach(function(s){ %>
+          <li>
+            <strong><%= s.name %></strong>
+            <% if (s.zip) { %><span class="supporter-zip"><%= s.zip %></span><% } %>
+            <% if (s.comment) { %><p class="supporter-comment">"<%= s.comment %>"</p><% } %>
+          </li>
+        <% }) %>
+      </ul>
+    </div>
+  <% } %>
+
+  <div class="reps-widget">
+    <h2>Find your representatives</h2>
+    <p>Enter your ZIP to see your state and get direct links to official, always-current .gov lookup tools.</p>
+    <form id="reps-lookup-form" class="inline-form">
+      <input type="text" id="reps-zip" placeholder="ZIP code" maxlength="10">
+      <button type="submit" class="btn btn-ghost btn-sm">Look up</button>
+    </form>
+    <div id="reps-result"></div>
+  </div>
+</section>
+
+<%- include('partials/foot') %>

(oldest)  ·  back to Petitionyour  ·  deploy: add http-only bootstrap vhost for certbot chicken-an 8d71624 →