diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf0e8ffce..8f69e7544 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,75 @@
# CHANGELOG
+## 2026-10-13 — Camera coverage beyond El Paso (prepared)
+
+- Added a camera-area selector for all nine corridors. The camera area follows
+ an explicit dashboard area change, while its own picker does not change the
+ wait-time filter. Changing areas/crossings and collapsing remove the player.
+- Added San Ysidro and Otay Mesa Caltrans U.S. road approach viewers, explicitly
+ distinguished from Mexico-to-U.S. inspection queues in visible copy, search
+ titles and no-JavaScript snapshots. Added four officially published Eagle
+ Pass Bridge II player views, without inferring their lane/orientation.
+- Added external official camera-source paths for Nogales, Laredo and Pharr.
+ Pharr's source belongs only to port 230502, not nearby Anzalduas. Calexico,
+ Brownsville and Progreso show an explicit unverified-coverage state.
+- Caltrans uses its published still images with manual refresh, original-image
+ timestamp guidance and error fallback. Both San Diego sources showed the
+ provider's Temporarily Unavailable placeholder during the browser check.
+- Generalized source/disclosure/privacy copy for YouTube, Caltrans and
+ IPCamLive; retained source/fallback links. No paid camera license, video relay,
+ new dependency or production release. Provider playback is not established
+ by mocked tests; exact verification evidence belongs to PR #132.
+
+## 2026-10-13 — Homepage camera access (prepared)
+
+- Added a visible Camera feeds section beside the local-area controls. Visitors
+ can select one of five verified El Paso–Juárez bridges and open the existing
+ camera player directly on the homepage, without finding a detail-page link.
+- Opening the section makes no player request. Selecting a view loads one
+ player; changing bridges or collapsing the section removes it. Coverage is
+ labeled explicitly, and English/Spanish copy and provider fallbacks are shared
+ with the crossing-detail component.
+- Added desktop/mobile EN/ES release checks for discovery, bridge switching,
+ deferred loading, cleanup and viewport fit. Direct inspection found both Paso
+ del Norte players rendered but buffering in the cloud browser; live playback
+ and source uptime remain unverified. Final release evidence belongs to PR #132.
+
+## 2026-10-13 — UX and crawlable-report QC (prepared)
+
+- Captured the live competitor and BorderPulse homepage in the cloud browser.
+ The observed first-screen gap is local decisions versus border-wide charts.
+ Moved the summary/chart behind an accessible disclosure, added quick local
+ choices, and made corridor selection a shareable `?area=` URL with Back
+ navigation and canonical homepage identity preserved.
+- Added familiar El Paso–Juárez crossing names and accent-insensitive aliases
+ for local search, headings and search titles without changing port identity.
+- Homepage and all 42 canonical crossing pages now deliver visible report HTML
+ before JavaScript. The original retrieval timestamp, raw lane clocks, missing
+ reports and closure state stay explicit. Other routes do not inherit this
+ homepage snapshot; no new page inventory or guessed rich-result schema.
+- Added snapshot/no-JavaScript assertions and expanded local-area browser
+ checks. No ranking, Core Web Vitals or acquisition improvement is claimed;
+ current Search Console reads were unavailable because the connected provider
+ reported an ended trial. Exact release evidence belongs to the pull request.
+
+## 2026-10-13 — Local comparison and camera feature batch (prepared)
+
+- Added nine named crossing corridors, remembered in browser storage and
+ compatible with existing state/search filters. El Paso–Juárez includes
+ Santa Teresa in New Mexico without treating the entire state as local.
+- Added northbound comparisons for standard passenger, Ready Lane, SENTRI
+ and pedestrian lanes. Missing reports, closed ports/lanes and unverifiable
+ operating hours are excluded. Ties, fewer than two available reports and
+ stale/unknown/future snapshot times suppress a shortest-wait claim. Report
+ clocks remain separate from retrieval time; extra driving is not estimated.
+- Added ten click-loaded camera views at five Juárez–El Paso bridges from
+ the official Fideicomiso directory, with view switching, closing, source and
+ playback fallback links, EN/ES provider disclosure and privacy copy.
+- Added comparison eligibility tests and desktop/mobile EN/ES interactions
+ to the existing release gate. No new npm dependency, account, database,
+ subscription, email service or production workflow change. Publication and
+ validation evidence belongs to the scoped pull request.
+
## 2026-10-13 — Document language ownership
- Metadata cleanup preserves the document language during crossing data and
diff --git a/DECISIONS.md b/DECISIONS.md
index d39c16dd1..c84d4ed08 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1,5 +1,41 @@
# DECISIONS
+## 2026-10-13 · Camera coverage cannot substitute nearby roads for inspection queues
+
+Use an explicit camera-area picker and distinguish embedded crossing views,
+Caltrans U.S. road approach views, and external official source pages. Areas
+with no verified source show that state instead of defaulting to El Paso.
+Keep canonical port identity: the Pharr source is not Anzalduas. Camera source
+review establishes provenance, not current playback or lane-specific conditions.
+
+## 2026-10-13 · Camera access belongs on the homepage
+
+The owner could not find feeds from the preview homepage. Give cameras their
+own visible entry beside local-area selection, with explicit El Paso–Juárez
+coverage and an inline bridge/view picker. Reuse the existing player and
+disclosures; selecting a view remains the only action that loads the provider,
+and bridge changes/collapse remove playback.
+
+## 2026-10-13 · Local decisions lead; published reports remain useful without JS
+
+Use familiar names as display/search aliases, preserving canonical CBP identity.
+Keep the border-wide chart available behind a disclosure and make local area
+selection shareable without creating duplicate indexable URLs. Deliver visible,
+timestamped HTML inside the React root on the homepage and crossing pages;
+createRoot replaces it with the refreshing app. Do not hide different report
+content for crawlers, imply a published snapshot is live, or expand thin SEO
+routes while current query/ranking evidence is unavailable.
+
+## 2026-10-13 · Local comparisons use eligible lanes; cameras load on request
+
+The owner requested competitor-inspired crossing features. Compare named
+corridors and the same northbound lane type without estimating total trip time.
+Suppress comparative winners on stale/unknown retrievals or unavailable hours;
+camera views are orientation, not proof of lane-specific conditions. Load the
+public YouTube player only after a user selects a camera; use official-source
+and provider fallback links. Real alerts/community reports remain separate
+backend work and do not override the ban on non-delivering notification CTAs.
+
Short log of *why* something is the way it is. Append-only. Future-you (or
the AI you're collaborating with) reads this when "wait, why did we…"
comes up.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9333e154e..d1bb53438 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -21,6 +21,30 @@ cross-border experience; it is a product hypothesis, not a claim of demand.
### Now: earn trust in the free entry point
+- [~] QC the October 13 locality-first flow, shareable area URLs and familiar
+ crossing names, with a visible inline camera entry on the homepage. Deliver
+ useful visible homepage/crossing snapshots without
+ JavaScript, preserving timestamps and canonical identity. Validation and
+ preview evidence belong to PR #132; not published yet.
+- [ ] Reconnect an existing authorized Search Console data path to measure
+ query/page impressions, clicks, CTR and area-to-crossing actions after release.
+ GSC Wizard reported an ended trial during QC; no subscription was started.
+
+- [~] October 13 competitor feature batch: nine remembered local corridor
+ filters, comparisons of the selected eligible lane and click-loaded public
+ camera views across El Paso–Juárez, San Diego–Tijuana and Eagle Pass, with
+ external official source paths for Nogales, Laredo and Pharr. Prepared on the feature branch;
+ production publication is not implied. Keep raw CBP report times visible and
+ exclude closed/unverifiable hours, missing reports and stale winner claims.
+- [ ] Verify permissible camera sources for Calexico–Mexicali, Brownsville and
+ Progreso, and real playback of the prepared viewers. Public source directories
+ and road approaches do not establish inspection-queue or live-stream coverage.
+- [ ] Real email threshold alerts and community crossing reports: implement
+ only with a working delivery channel and shared storage. See
+ `docs/CROSSING-FEATURES.md` for the concrete follow-up contract, source receipts,
+ operating costs and validation requirements. No signup/report form until the
+ service actually accepts and delivers it.
+
- [x] Correct route hreflangs and bound metadata-navigation checks (PR129/130).
Production was verified on October 12 at `2b40cb2b`, including reciprocal
pairs and exact served assets. Those releases are complete; preserve them.
diff --git a/docs/CROSSING-FEATURES.md b/docs/CROSSING-FEATURES.md
new file mode 100644
index 000000000..b2f36c494
--- /dev/null
+++ b/docs/CROSSING-FEATURES.md
@@ -0,0 +1,143 @@
+# Crossing feature batch — October 13, 2026
+
+## Prepared implementation
+
+The dashboard's crossing-area selector remembers one of nine named corridors.
+Selecting a state clears that corridor; selecting a corridor clears the search
+and state scope. Existing public routes, source data, collection, ads and
+publication gates stay intact. Local comparisons include the selected lane
+only; a shorter reported queue is not a shorter total journey.
+
+Public camera players are available on Paso del Norte, Stanton-Lerdo,
+Ysleta-Zaragoza, Tornillo and Bridge of the Americas detail pages. The regional
+panel links directly to each page's camera section. No video, thumbnail or
+provider request is made by this feature before a view is selected. Selecting
+another view replaces the iframe; closing removes it. There is no video relay,
+recording, paid routing call, new SDK or new secret. Availability is controlled
+by the source and is not inferred from iframe load success.
+
+The homepage also has a visible Camera feeds section with explicit El Paso–Juárez
+coverage. Opening it exposes the bridge/view controls without creating a player.
+Selecting a view loads the same component used on crossing pages. Changing the
+bridge or collapsing the section removes the player. This path addresses the
+owner's difficulty finding the feeds in the preview.
+
+## Camera source receipts
+
+Official directory: https://www.puentesfronterizos.gob.mx/camaras-en-vivo.php
+
+The directory lists each orientation separately. On review, its CamStreamer
+links resolve to the following YouTube player IDs; BOTA links point to YouTube
+directly. These are public-source embeds, not scraped competitor video assets.
+The displayed orientation is the official heading, not inferred travel direction.
+
+| Canonical port | Official views | Player IDs |
+| --- | --- | --- |
+| 240202 Paso del Norte | North to south / South to north | IcvugJWPXz8 / 0Pg3S6s76IE |
+| 240204 Stanton-Lerdo | North to south / South to north | CYhISA_wFQ8 / sOHFG6_3zAs |
+| 240104 Ysleta-Zaragoza | North to south / South to north | GC5RY3zipa4 / KX-bm-Lhy3w |
+| 240401 Tornillo | North to south / South to north | 0gT7jvaLCkg / 8u-hBawdG8c |
+| 240201 BOTA | North / South | mp3RS0y77tY / CZM5TpXLzE8 |
+
+Review the IDs against the directory when playback stops: livestream IDs can
+change. The official directory remains the fallback. Santa Teresa has no
+verified stream in this registry. City of El Paso's own directory reported
+camera maintenance during review, so its streams are not advertised as working.
+Playback itself could not be established by text retrieval; browser tests mock
+or block providers and test the controls, not third-party stream uptime.
+
+## Expanded camera coverage — October 13
+
+The homepage camera-area picker includes all nine corridors. Six have reviewed
+source paths: 14 video-player views and two camera snapshots at eight crossings
+in three corridors, plus
+official external-source links at five crossings in three additional corridors.
+Calexico–Mexicali, Brownsville and Progreso have no source verified in this batch
+and show that explicitly. A reviewed source is not a stream-uptime guarantee.
+
+| Area | Canonical ports | Type and source receipt |
+| --- | --- | --- |
+| San Diego–Tijuana | 250401 San Ysidro; 250601 Otay Mesa | Caltrans U.S. road approaches; not the northbound inspection queue in Mexico. Official list: https://cwwp2.dot.ca.gov/vm/streamlist.htm |
+| Eagle Pass | 230302 Bridge II | Four iframe players published at https://www.eaglepasstx.gov/335/International-Bridge-2-Cameras ; alias IDs: 67231a475ead1, 639ba5d96b3f6, 639ba81bb4b49, 68f3f8b846277. Views are numbered because lane/orientation is unconfirmed. |
+| Nogales | 260401 DeConcini; 260402 Mariposa | External municipal directory: https://heroicanogales.gob.mx/webcams-garitas ; municipal source publication: https://heroicanogales.gob.mx/acciones/fortalecen-acceso-ciudadano-a-camaras-de-garita-mariposa |
+| Laredo | 230401 Bridge I; 230402 Bridge II | External municipal directory: https://www.cityoflaredo.com/services/bridge-cameras . Indexed official listing was retrieved; page fetch returned 403 during review, so no embed/playback is claimed. |
+| McAllen–Reynosa | 230502 Pharr | External official bridge site: https://bridge.pharr-tx.gov/ advertises its Live Bridge Camera link. The viewer fetch timed out during review; no embed/playback is claimed. This source must not be assigned to Anzalduas (230503). |
+
+Caltrans viewer pages inspected in the browser:
+- San Ysidro: https://cwwp2.dot.ca.gov/vm/loc/d11/c214sb5viadesanysidro.htm
+- Otay Mesa: https://cwwp2.dot.ca.gov/vm/loc/d11/c100i905siemprevivat.htm
+
+The viewer's video poster fields publish the snapshot URLs used in the app:
+- https://cwwp2.dot.ca.gov/data/d11/cctv/image/c214sb5viadesanysidro/c214sb5viadesanysidro.jpg
+- https://cwwp2.dot.ca.gov/data/d11/cctv/image/c100i905siemprevivat/c100i905siemprevivat.jpg
+
+These images load after view selection and refresh only on explicit request;
+request time is not displayed as capture time. Failed loads expose a visible
+error and an official video-viewer link. The provider's own Temporarily
+Unavailable placeholder remains visible when returned as an image. Both sources
+showed that placeholder during this review; playback/uptime is not claimed.
+
+Caltrans publishes these viewers through its official live-camera list and
+describes its own information as generally public domain in
+https://dot.ca.gov/conditions-of-use . Keep attribution and provider links.
+BorderTraffic's public page requires licensing for commercial use of its
+content; its free personal-use views are not copied or embedded here. No
+license was purchased and no provider was contacted.
+
+Area/crossing selection alone creates no provider iframe. Selecting one of the
+reviewed embedded views loads that provider's page; source-only options are
+ordinary outbound links. Existing detail cards, titles and visible static
+snapshots use the correct source and distinguish road approaches from queues.
+
+## Next: real email threshold alerts
+
+The existing product has no database or email delivery provider. Do not collect
+addresses or show a signup success until the full path exists. Prepare an
+explicit provider/storage choice and cost cap before provisioning anything.
+
+- Pilot one corridor. Subscription specifies canonical port, eligible lane,
+ threshold, language and email. Confirm the email before activation.
+- Durable storage needs subscription ID, email, confirmed status, port/lane,
+ threshold, confirmation/unsubscribe token hashes, and last delivery time.
+- Reuse the current validated CBP collector; do not add a per-subscriber polling
+ loop. A scheduled evaluator compares only open, reporting eligible lanes.
+- Trigger on crossing from above to below the threshold, at most one email per
+ subscriber per day. On stale/missing data, send nothing. Recovery must not
+ manufacture a threshold crossing from an unknown previous observation.
+- Idempotent delivery ledger prevents retry duplicates. Unsubscribe is present
+ in each email; remove/suppress addresses after bounce or complaint.
+- Validation: closed browser receives the confirmed test email; threshold
+ transition, stale feeds, duplicate cron runs, unsubscribe and provider errors
+ are tested. Never call an open-tab-only banner an alert.
+- Costs are unknown until the provider and expected volume are selected.
+ Calculate confirmed subscriptions × maximum deliveries/day, storage volume,
+ cron frequency and provider rate limits. Target no new monthly spend for the
+ pilot; alert at a reviewed cap and fail closed before paid overages.
+
+## Next: community crossing reports
+
+A public community feed needs shared storage and an abuse-control path; a
+local browser timer alone cannot provide community reports.
+
+- Collect canonical port, direction, lane, actual duration, completion time and
+ submission time. Do not require precise GPS, document data or free text.
+- Optional timer uses absolute start/end timestamps, survives reload in local
+ browser storage, and stops only on explicit completion. A saved local record
+ is labeled as such until a server confirms publication.
+- Validate numeric bounds, allowed port/lane combinations and recency on the
+ server. Rate-limit and deduplicate submissions; cap retention and volume.
+- Public read model shows report count, lane, time range and a summary with a
+ sample floor. Keep community observations separate from official CBP waits.
+- Do not claim improved estimates until held-out completed crossings show it.
+ Compare error by bridge/lane and quiet/busy periods; avoid validating on the
+ observations used to calibrate the model.
+- Validation: submission appears in a second browser; expired, implausible,
+ duplicate and rejected reports never create a misleading public estimate.
+
+## Competitor operating-cost observations
+
+Fila de Puentes credits Fideicomiso, CBP, TxDOT and public streams on its About
+page, and Fili's explanation states a daily message limit. Public embeds and
+message limits can reduce bandwidth/inference cost. Revenue, funding, actual
+traffic, model/provider and operating expenses remain unknown. Absence of a
+visible paid plan or ad unit does not establish that its costs are high.
diff --git a/package.json b/package.json
index ca1023408..0d98883d4 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes",
- "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs",
+ "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs scripts/test-crossing-snapshot.mjs",
"test:ads": "node --test scripts/test-ad-slot-diagnostics.mjs",
"test:publish": "node --test scripts/test-publish-data.mjs scripts/test-public-crossings.mjs",
"publish:validate": "node scripts/validate-publish-data.mjs",
@@ -35,7 +35,7 @@
"news:classify": "node scripts/build-curation-queue.mjs",
"test:bundle": "node scripts/check-bundle-size.mjs",
"test:audit": "node scripts/check-audit.mjs",
- "test:trust": "node --test scripts/test-trust-state.mjs",
+ "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs",
"test:mdx": "node --test scripts/test-mdx-frontmatter.mjs",
"test:writer": "node --test scripts/test-generated-writer.mjs",
"test:refresh": "node --test scripts/test-data-refresh.mjs"
diff --git a/scripts/check-faq-scope.mjs b/scripts/check-faq-scope.mjs
index 38087db0b..f07495bf9 100644
--- a/scripts/check-faq-scope.mjs
+++ b/scripts/check-faq-scope.mjs
@@ -105,7 +105,7 @@ export function checkBuiltFaqScope(distDir = path.join(root, 'dist')) {
{ route: '/metodologia/', title: 'Metodología |', lang: 'es', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') },
{ route: '/privacy/', title: 'Privacy and advertising |', hreflangs: twins('/privacy/', '/privacidad/') },
{ route: '/privacidad/', title: 'Privacidad y publicidad |', lang: 'es', hreflangs: twins('/privacy/', '/privacidad/') },
- { route: '/crossing/san-ysidro/', title: 'San Ysidro Border Wait Time', types: ['Place', 'FAQPage', 'BreadcrumbList'] },
+ { route: '/crossing/san-ysidro/', title: 'San Ysidro: CBP wait times', types: ['Place', 'FAQPage', 'BreadcrumbList'] },
{ route: '/blog/', title: 'Border Pulse Blog |', types: ['Blog', 'BreadcrumbList'] },
{ route: enArticle, title: 'San Ysidro vs Otay Mesa:', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) },
{ route: esArticle, title: 'San Ysidro vs Otay Mesa:', lang: 'es', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) },
diff --git a/scripts/check-local-crossings.mjs b/scripts/check-local-crossings.mjs
new file mode 100644
index 000000000..1e510d9e0
--- /dev/null
+++ b/scripts/check-local-crossings.mjs
@@ -0,0 +1,154 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+
+export async function checkLocalCrossings(page, context, baseUrl) {
+ const fixture = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url)));
+ fixture.fetched_at = new Date().toISOString();
+ for (const crossing of fixture.crossings) {
+ if (!['240202', '240201', '240104', '240204', '240801', '240401'].includes(crossing.port_number)) continue;
+ crossing.hours = '24 hrs/day';
+ crossing.port_status = 'Open';
+ crossing.current_wait_time = crossing.port_number === '240801' ? 0 : 30;
+ crossing.lanes = {
+ passenger_standard: { status: 'Delay', lanes_open: 1, delay_minutes: crossing.port_number === '240801' ? 0 : 30, update_time: '12:00 PM MDT' },
+ passenger_sentri: { status: 'No Delay', lanes_open: 1, delay_minutes: 0, update_time: '12:00 PM MDT' },
+ };
+ }
+ const dataPattern = '**/data/crossings.json*';
+ const apiPattern = '**/api/public/crossings*';
+ const feed = route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(fixture) });
+ await page.route(dataPattern, feed);
+ await page.route(apiPattern, feed);
+ const cameraImagePattern = 'https://cwwp2.dot.ca.gov/data/d11/cctv/image/**';
+ const cameraImage = route => route.fulfill({ contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC', 'base64') });
+ await page.route(cameraImagePattern, cameraImage);
+ try {
+ for (const lang of ['en', 'es']) {
+ await page.goto(new URL('/', baseUrl).href);
+ await page.evaluate(value => {
+ localStorage.setItem('borderPulse_language', value);
+ localStorage.setItem('borderPulse_region', 'ALL');
+ localStorage.removeItem('borderPulse_corridor');
+ }, lang);
+ await page.reload();
+ const chooser = page.getByLabel(lang === 'es' ? 'Tu zona de cruce' : 'Your crossing area');
+ await chooser.selectOption('el-paso-juarez');
+ await page.waitForURL(url => url.searchParams.get('area') === 'el-paso-juarez');
+ const cameraHub = page.getByRole('region', { name: lang === 'es' ? 'Cámaras fronterizas' : 'Border cameras', exact: true });
+ const cameraToggle = cameraHub.getByRole('button', { name: lang === 'es' ? 'Cámaras' : 'Camera feeds', exact: true });
+ assert.equal(await cameraHub.locator('iframe').count(), 0);
+ await cameraToggle.click();
+ const bridgeChooser = cameraHub.getByRole('group', { name: lang === 'es' ? 'Elegir cruce' : 'Choose a crossing', exact: true });
+ assert.equal(await bridgeChooser.getByRole('button').count(), 5);
+ assert.equal(await cameraHub.locator('iframe').count(), 0, 'opening the camera hub alone must not load a provider');
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click();
+ const inlineCamera = cameraHub.locator('iframe');
+ await inlineCamera.waitFor();
+ assert.match(await inlineCamera.getAttribute('src'), /IcvugJWPXz8/);
+ await bridgeChooser.getByRole('button', { name: 'Zaragoza · Ysleta', exact: true }).click();
+ assert.equal(await cameraHub.locator('iframe').count(), 0, 'switching bridges closes the previous player');
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click();
+ assert.equal(await cameraHub.locator('iframe').count(), 1);
+ assert.match(await inlineCamera.getAttribute('src'), /GC5RY3zipa4/);
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'inline cameras must fit the viewport');
+ const cameraArea = cameraHub.getByLabel(lang === 'es' ? 'Zona de cámaras' : 'Camera area');
+ await cameraArea.selectOption('san-diego-tijuana');
+ assert.equal(await cameraHub.locator('iframe').count(), 0, 'changing areas removes the previous player');
+ assert.equal(await bridgeChooser.getByRole('button').count(), 2);
+ await bridgeChooser.getByRole('button', { name: 'San Ysidro', exact: true }).click();
+ await cameraHub.getByText(lang === 'es' ? /no muestran la fila de inspección/ : /do not show the inspection queue/).waitFor();
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'I-5 · Via de San Ysidro (lado EE. UU.)' : 'I-5 · Via de San Ysidro (U.S. road)', exact: true }).click();
+ const cameraSnapshot = cameraHub.getByRole('img');
+ await cameraSnapshot.waitFor();
+ const originalImageUrl = await cameraSnapshot.getAttribute('src');
+ assert.equal(new URL(originalImageUrl).hostname, 'cwwp2.dot.ca.gov');
+ assert.equal(await cameraHub.locator('iframe').count(), 0, 'Caltrans snapshots do not load a video player');
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'Actualizar imagen' : 'Refresh image', exact: true }).click();
+ assert.notEqual(await cameraSnapshot.getAttribute('src'), originalImageUrl, 'manual refresh requests a new image without claiming a new capture time');
+ await page.route(cameraImagePattern, route => route.abort('blockedbyclient'));
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'Actualizar imagen' : 'Refresh image', exact: true }).click();
+ await cameraHub.getByText(lang === 'es' ? /Imagen no disponible/ : /Camera image unavailable/).waitFor();
+ assert.equal(await cameraHub.getByRole('link', { name: lang === 'es' ? 'Abrir visor de video en Caltrans' : 'Open the Caltrans video viewer', exact: true }).getAttribute('href'), 'https://cwwp2.dot.ca.gov/vm/loc/d11/c214sb5viadesanysidro.htm');
+ await page.unroute(cameraImagePattern);
+ await page.route(cameraImagePattern, cameraImage);
+ assert.equal(new URL(page.url()).searchParams.get('area'), 'el-paso-juarez', 'camera selection must not silently change the wait-time area');
+ await cameraArea.selectOption('eagle-pass');
+ assert.equal(await cameraHub.locator('iframe').count(), 0);
+ await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista 1 del Puente II' : 'Bridge II view 1', exact: true }).click();
+ assert.match(await inlineCamera.getAttribute('src'), /^https:\/\/g1\.ipcamlive\.com\/player\/player.php\?alias=67231a475ead1$/);
+ await cameraArea.selectOption('nogales');
+ assert.equal(await cameraHub.locator('iframe').count(), 0);
+ assert.equal(await cameraHub.getByRole('link', { name: lang === 'es' ? 'Abrir fuente oficial de cámaras' : 'Open official camera source', exact: true }).getAttribute('href'), 'https://heroicanogales.gob.mx/webcams-garitas');
+ await cameraArea.selectOption('calexico-mexicali');
+ await cameraHub.getByText(lang === 'es' ? /Aún no hay una cámara pública verificada/ : /No public camera has been verified/).waitFor();
+ assert.equal(await cameraHub.locator('iframe').count(), 0);
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'area selector and missing-camera state must fit the viewport');
+ await cameraToggle.click();
+ assert.equal(await cameraHub.locator('iframe').count(), 0, 'collapsing the camera hub stops the player');
+ assert.equal(await bridgeChooser.count(), 0);
+ await page.getByRole('button', { name: lang === 'es' ? 'Compartir' : 'Share', exact: true }).click();
+ const dialog = page.getByRole('dialog');
+ await dialog.getByRole('button', { name: lang === 'es' ? 'Copiar para WhatsApp' : 'Copy for WhatsApp', exact: true }).click();
+ const shareText = await page.evaluate(() => window.copiedLink);
+ assert.ok(shareText.includes('https://borderpulse.com/?area=el-paso-juarez'));
+ assert.ok(!/\d+\s*min/.test(shareText), 'local sharing must not carry a cached wait claim');
+ await page.keyboard.press('Escape');
+ const panel = page.getByRole('region', { name: lang === 'es' ? 'Comparación local' : 'Local comparison', exact: true });
+ await panel.waitFor();
+ await panel.getByText(/Santa Teresa/).first().waitFor();
+ await page.waitForFunction(() => document.querySelector('[data-testid="local-comparison-summary"]')?.textContent.includes('0 min'), null, { timeout: 5_000 });
+ assert.equal(await panel.getByRole('link', { name: /San Ysidro/ }).count(), 0);
+ assert.equal(await panel.getByRole('link', { name: lang === 'es' ? 'Ver cámaras' : 'View cameras', exact: true }).count(), 5);
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'regional view must fit viewport');
+ await page.reload();
+ assert.equal(await chooser.inputValue(), 'el-paso-juarez');
+ const lane = panel.getByLabel(lang === 'es' ? 'Tu carril' : 'Your lane');
+ await lane.selectOption('passenger_sentri');
+ await panel.getByText(lang === 'es' ? 'Solo compara SENTRI si cumples los requisitos de ese carril.' : 'Compare SENTRI only if you meet that lane’s requirements.', { exact: true }).waitFor();
+ assert.ok(!(await panel.locator('[data-testid="local-comparison-summary"]').innerText()).includes('0 min'), 'ties cannot claim a shortest option');
+ await lane.selectOption('pedestrian_standard');
+ assert.equal(await panel.getByText(lang === 'es' ? 'Sin reporte: Peatones' : 'No pedestrian report', { exact: true }).count(), 6);
+ await page.evaluate(() => {
+ const original = Storage.prototype.setItem;
+ Storage.prototype.setItem = function(key, value) {
+ if (key === 'borderPulse_corridor') throw new Error('storage denied');
+ return original.call(this, key, value);
+ };
+ });
+ await chooser.selectOption('san-diego-tijuana');
+ await panel.getByRole('heading', { name: 'San Diego–Tijuana', exact: true }).waitFor();
+ await page.reload();
+ assert.equal(await chooser.inputValue(), 'san-diego-tijuana', 'the URL remains usable when browser storage is blocked');
+ assert.equal(await page.evaluate(() => localStorage.getItem('borderPulse_corridor')), 'el-paso-juarez', 'blocked storage does not claim a saved preference');
+ await chooser.selectOption('el-paso-juarez');
+ await panel.getByRole('heading', { name: 'El Paso–Juárez', exact: true }).waitFor();
+ const cameraLink = panel.getByRole('link', { name: lang === 'es' ? 'Ver cámaras' : 'View cameras', exact: true }).first();
+ await cameraLink.click();
+ await page.waitForURL(url => url.hash === '#cameras');
+ const cameraPanel = page.getByRole('region', { name: lang === 'es' ? 'Cámaras del cruce' : 'Crossing cameras', exact: true });
+ await cameraPanel.waitFor();
+ assert.equal(await cameraPanel.locator('iframe').count(), 0, 'no player loaded before selection');
+ await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click();
+ const frame = cameraPanel.locator('iframe');
+ await frame.waitFor();
+ assert.match(await frame.getAttribute('src'), /^https:\/\/www\.youtube-nocookie\.com\/embed\/IcvugJWPXz8\?/);
+ await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Vista de sur a norte' : 'South to north view', exact: true }).click();
+ assert.equal(await cameraPanel.locator('iframe').count(), 1);
+ assert.match(await frame.getAttribute('src'), /0Pg3S6s76IE/);
+ await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Cerrar cámara' : 'Close camera', exact: true }).click();
+ assert.equal(await cameraPanel.locator('iframe').count(), 0);
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'camera panel must fit viewport');
+ await page.goto(new URL('/', baseUrl).href);
+ await page.getByRole('button', { name: 'California', exact: true }).click();
+ assert.equal(await chooser.inputValue(), '');
+ assert.equal(await page.getByRole('region', { name: lang === 'es' ? 'Comparación local' : 'Local comparison', exact: true }).count(), 0);
+ assert.equal(new URL(page.url()).searchParams.has('area'), false);
+ }
+ } finally {
+ await page.unroute(dataPattern, feed);
+ await page.unroute(apiPattern, feed);
+ await page.unroute(cameraImagePattern);
+ await page.evaluate(() => localStorage.removeItem('borderPulse_corridor'));
+ }
+ console.log('[local-crossings] PASS: EN/ES corridor persistence, lane eligibility, storage failure, camera navigation, deferred player, view switching and close');
+}
diff --git a/scripts/check-nojs-reports.mjs b/scripts/check-nojs-reports.mjs
new file mode 100644
index 000000000..62f1ca596
--- /dev/null
+++ b/scripts/check-nojs-reports.mjs
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+
+// This is a no-JavaScript visitor, not a special Googlebot response.
+export async function checkNoJsReports(browser, baseUrl) {
+ const context = await browser.newContext({ javaScriptEnabled: false,
+ viewport: { width: 375, height: 812 }, serviceWorkers: 'block' });
+ await context.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin
+ ? route.continue() : route.abort('blockedbyclient'));
+ const page = await context.newPage();
+ try {
+ await page.goto(new URL('/', baseUrl).href);
+ await page.getByRole('heading', { name: 'US–Mexico border wait times', exact: true }).waitFor();
+ const report = page.locator('[data-prerender="crossing-snapshot"]');
+ assert.equal(await report.locator('tbody tr').count(), 42);
+ await report.getByText(/published snapshot, not a live feed/).waitFor();
+ assert.ok(await report.getByRole('link', { name: 'El Paso–Juárez', exact: true }).isVisible());
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'no-JS homepage must fit mobile viewport');
+ await report.getByRole('link', { name: 'Santa Fe · Paso del Norte', exact: true }).click();
+ await page.getByRole('heading', { name: 'Santa Fe · Paso del Norte border wait times', exact: true }).waitFor();
+ assert.equal(await report.locator('tbody tr').count(), 1);
+ assert.ok(await report.getByRole('link', { name: 'View official camera sources: Fideicomiso de Puentes Fronterizos de Chihuahua', exact: true }).isVisible());
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'no-JS crossing must fit mobile viewport');
+ } finally { await context.close(); }
+ console.log('[nojs-reports] PASS: mobile homepage → crossing → official camera path, visible reports, original timestamps and viewport fit without app JavaScript');
+}
diff --git a/scripts/lib/crossing-snapshot-html.mjs b/scripts/lib/crossing-snapshot-html.mjs
new file mode 100644
index 000000000..228e7c8c8
--- /dev/null
+++ b/scripts/lib/crossing-snapshot-html.mjs
@@ -0,0 +1,42 @@
+import { availableLaneWait } from '../../src/lib/crossingAvailability.js';
+import { crossingDisplayName } from '../../src/lib/crossingNames.js';
+import { CORRIDORS } from '../../src/lib/corridors.js';
+import { cameraSourceFor } from '../../src/lib/cameras.js';
+
+const esc = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+// Visible, useful HTML for every visitor before the app loads, including when
+// JavaScript is unavailable. createRoot replaces it with the updating app.
+// The original snapshot timestamp is never rewritten into a "live" promise.
+export function snapshotHtml(crossings, portToSlug, fetchedAt, single = false) {
+ const heading = single
+ ? `${crossingDisplayName(crossings[0])} border wait times`
+ : 'US–Mexico border wait times';
+ const areas = single ? '' : ``;
+ const rows = crossings.map(c => {
+ const slug = portToSlug[c.port_number];
+ const lane = c.lanes?.passenger_standard;
+ const wait = availableLaneWait(c, 'passenger_standard');
+ const closed = (c.port_status || '').trim().toLowerCase() === 'closed';
+ const value = closed ? 'Closed in this snapshot' : wait == null ? 'No standard-lane report' : `${wait} min`;
+ return `
View official camera sources: ${esc(cameraSource.owner)}. ${cameraSource.kind === 'approach' ? 'These are U.S. road approaches, not the inspection queue from Mexico into the U.S.' : 'Camera access and availability are controlled by the provider.'}
` : '';
+ return `
+
${esc(heading)}
+
Official CBP northbound reports, crossing hours, local comparisons and historical planning. English and Spanish are available in the interactive app.
+
Published snapshot fetched: . This is a published snapshot, not a live feed. Individual lane report times can be older. Check the interactive report or CBP before leaving.
+ ${areas}
+
Reported standard passenger waits to the U.S.
Crossing
Reported wait
CBP report clock
Posted hours
${rows}
+ ${cameraLink}
+
Consider your eligible lane, extra driving and destination. These numbers do not compare total trip time. Data methodology.