From a8d6622b88bd4f54bc8e469d4096b0ec6009e7bf Mon Sep 17 00:00:00 2001
From: sbc1-code <207095575+sbc1-code@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:54:14 -0700
Subject: [PATCH 1/2] fix: tolerate browser storage failures and clean route
navigation
---
CHANGELOG.md | 31 +++++++
DECISIONS.md | 15 ++++
ROADMAP.md | 10 +++
docs/REFRESH-OPERATIONS.md | 26 ++++++
package.json | 2 +-
scripts/check-browser-resilience.mjs | 126 +++++++++++++++++++++++++++
scripts/prerender.mjs | 2 +-
scripts/smoke-routes.mjs | 2 +
scripts/test-browser-state.mjs | 54 ++++++++++++
scripts/test-hreflang.mjs | 15 ++++
src/Layout.jsx | 32 +++----
src/lib/browserStorage.js | 23 +++++
src/lib/seo.js | 11 +++
src/lib/useLanguage.js | 12 ++-
src/pages/About.jsx | 12 +--
src/pages/BestTime.jsx | 16 ++--
src/pages/BlogPost.jsx | 8 +-
src/pages/Compare.jsx | 4 +-
src/pages/CrossingDetail.jsx | 18 ++--
src/pages/Dashboard.jsx | 66 ++++++--------
src/pages/Methodology.jsx | 8 +-
21 files changed, 399 insertions(+), 94 deletions(-)
create mode 100644 scripts/check-browser-resilience.mjs
create mode 100644 scripts/test-browser-state.mjs
create mode 100644 src/lib/browserStorage.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2183d30f0..c9c25fff7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# CHANGELOG
+## 2026-10-21 — Browser resilience and navigation (prepared)
+
+- Keep the app usable when browser storage is blocked or contains malformed
+ preferences. Validate saved favorites and language/theme values; show an
+ EN/ES failure notice instead of claiming a favorite was saved. Language and
+ theme controls still work in the open document when persistence is denied.
+- Move skip-link focus into the main content, close mobile navigation with
+ Escape and return focus, and replace nested link/button back controls with
+ single links with 44px minimum height.
+- Update Twitter metadata with each page and remove a previous route's
+ generated schema after client navigation. Preserve same-route and shared
+ schema. Full runtime schema generation remains separate work.
+- Correct About's EN/ES description of existing locally saved rounded
+ coordinates. No new collection, transmission, dependency or service.
+- Validation: 118 unit tests, production build and full npm test passed,
+ including 416 desktop/mobile navigations and EN/ES storage-failure,
+ keyboard, metadata, sharing, feedback and freshness regressions. These are
+ bounded checks, not a security or accessibility certification.
+
+## 2026-10-21 — Compare and Vite production verification
+
+- PR #136 merged at `f55167b6994c7b3e6532529566507352a7ff478d`.
+ Actions run `39561576557` succeeded; Vercel deployment
+ `dpl_3fkPhCsCiPdpq62RhzNPfCU1b4yg` was READY with the same commit.
+ The public publication manifest and served app were verified on
+ borderpulse.com in EN/ES at desktop/mobile sizes, with synthetic fresh,
+ stale and future snapshots kept separate from the live feed readback.
+- The two October 20 local-review entries below are now published. Vite is
+ 6.4.4; the audit gate reports zero high/critical and eight remaining moderate
+ package entries. No audit exception or security-setting change was made.
+
## 2026-10-20 — Vite audit unblock (local review)
- Update the existing Vite development dependency from locked 6.4.3 to 6.4.4,
diff --git a/DECISIONS.md b/DECISIONS.md
index c84d4ed08..c2a32c1e4 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1,5 +1,20 @@
# DECISIONS
+## 2026-10-21 · Browser persistence is optional and saved preferences are untrusted
+
+A blocked localStorage accessor or a parsed non-array favorite value must not
+break crossing decisions. Normalize saved preferences, keep temporary language
+and theme choices usable, and report favorite-write failure without emitting
+a success event. Describe the existing rounded-coordinate storage accurately;
+do not add data collection or change consent, ads or security settings.
+
+## 2026-10-21 · Route transitions must not inherit another page's schema
+
+Mark generated route schema with its canonical URL and remove it when that
+canonical changes. Keep it during same-route data/language updates and retain
+shared schema. Synchronize Twitter and Open Graph previews. This bounded
+cleanup does not promise schema regeneration for every client-side route.
+
## 2026-10-13 · Camera coverage cannot substitute nearby roads for inspection queues
Use an explicit camera-area picker and distinguish embedded crossing views,
diff --git a/ROADMAP.md b/ROADMAP.md
index d1bb53438..218c57434 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -21,6 +21,16 @@ cross-border experience; it is a product hypothesis, not a claim of demand.
### Now: earn trust in the free entry point
+- [x] Publish the pair-comparison retrieval guard and Vite 6.4.4 patch (PR #136).
+ Production commit `f55167b6`, Actions run `39561576557`, manifest and served
+ EN/ES desktop/mobile app were verified October 21. Eight moderate audit
+ package entries remain; no usage or conversion increase is established.
+- [~] Prepare bounded browser-storage, keyboard-navigation and route-metadata
+ corrections from the October 21 audit. Local build, 118 unit tests and full
+ npm test passed; review and release evidence belong to the follow-up PR.
+ Broader schema lifecycle, assistive-technology and cross-browser audits
+ remain separate work.
+
- [~] 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
diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md
index eb38e70e4..9e958479c 100644
--- a/docs/REFRESH-OPERATIONS.md
+++ b/docs/REFRESH-OPERATIONS.md
@@ -1,5 +1,31 @@
# Public data refresh
+## Compare and Vite release — 2026-10-21 UTC
+
+- [PR #136](https://github.com/sbc1-code/borderpulse/pull/136) merged at
+ `f55167b6994c7b3e6532529566507352a7ff478d`. Its separate commits guard
+ untrusted pair-comparison snapshots and patch Vite 6.4.3 to 6.4.4.
+- Both CI runs passed. The existing production
+ [Actions run 39561576557](https://github.com/sbc1-code/borderpulse/actions/runs/39561576557)
+ succeeded. Vercel deployment `dpl_3fkPhCsCiPdpq62RhzNPfCU1b4yg`,
+ `borderpulse-1byel2036-sbc1-codes-projects.vercel.app`, was READY with that
+ commit and the existing borderpulse.com/www aliases. No hosting migration,
+ account, permission, security setting or paid-service change was needed.
+- Public `https://borderpulse.com/data/publication.json` matched the merge
+ commit, prepared at `2026-10-21T07:23:29.976Z`; its 42-crossing snapshot,
+ 2,379-entry history and FX input matched their published timestamps.
+ Separate real-feed readback and synthetic fresh/stale/future tests against
+ the served app passed in EN/ES on desktop/mobile. This is a release receipt,
+ not a claim that the live feed failed or that future publications are healthy.
+- Fresh local build/full npm test passed with 416 route navigations. The
+ release had 114 unit tests; the subsequent bounded audit follow-up has 118.
+ Audit reports zero high/critical and eight moderate package entries outside
+ this patch. Limited browser/code checks do not establish complete security,
+ accessibility, provider playback, search performance or conversion lift.
+- Existing collector, Vercel publication path, Pages rollback, paused product
+ automation and advertising configuration remain as previously recorded.
+ Preserve the original operator checkout's 18 unrelated changed paths.
+
## AdSense preparation — 2026-10-17 UTC
BorderPulse and DIGITO are separate site entries under the existing publisher
diff --git a/package.json b/package.json
index bea9c25e8..6c8106470 100644
--- a/package.json
+++ b/package.json
@@ -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 scripts/test-local-comparison.mjs",
+ "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.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-browser-resilience.mjs b/scripts/check-browser-resilience.mjs
new file mode 100644
index 000000000..f3bc2d67b
--- /dev/null
+++ b/scripts/check-browser-resilience.mjs
@@ -0,0 +1,126 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+
+// Synthetic data only; reuse the route suite's external-request blocker.
+export async function checkBrowserResilience(owner, context, baseUrl) {
+ const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url)));
+ snapshot.fetched_at = new Date().toISOString();
+ const keys = ['borderPulse_language', 'borderPulse_theme', 'borderPulse_region', 'borderPulse_favorites'];
+ const saved = await owner.evaluate(keys => Object.fromEntries(keys.map(key => [key, localStorage.getItem(key)])), keys);
+ const feedMatch = url => ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname);
+ const feedHandler = route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(snapshot) });
+ const errors = [];
+ await context.route(feedMatch, feedHandler);
+ const newPage = async () => {
+ const page = await context.newPage();
+ page.setDefaultTimeout(8_000);
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => {
+ if (message.type() === 'error' && /ErrorBoundary|Uncaught|ReferenceError|TypeError|SyntaxError/.test(message.text())) errors.push(message.text());
+ });
+ return page;
+ };
+ try {
+ for (const language of ['en', 'es']) {
+ const searchName = language === 'en' ? 'Search crossings by name or city' : 'Buscar cruces por nombre o ciudad';
+ const add = language === 'en' ? 'Add to favorites' : 'Agregar a favoritos';
+ const failedMessage = language === 'en'
+ ? 'Your browser could not save this change. Allow site storage and try again.'
+ : 'Tu navegador no pudo guardar este cambio. Permite el almacenamiento del sitio e inténtalo de nuevo.';
+ for (const failure of ['getter', 'quota']) {
+ const page = await newPage();
+ try {
+ await page.addInitScript(({ failure, language }) => {
+ localStorage.setItem('borderPulse_language', language);
+ localStorage.setItem('borderPulse_favorites', '[]');
+ window.resilienceEvents = [];
+ window.umami = { track: (event, data) => window.resilienceEvents.push({ event, data }) };
+ if (failure === 'getter') Object.defineProperty(window, 'localStorage', {
+ configurable: true, get() { throw new DOMException('Fixture blocked storage', 'SecurityError'); },
+ });
+ else Storage.prototype.setItem = () => { throw new DOMException('Fixture quota', 'QuotaExceededError'); };
+ }, { failure, language });
+ await page.goto(baseUrl.href);
+ if (language === 'es') await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
+ await page.getByRole('searchbox', { name: searchName, exact: true }).waitFor();
+ assert.equal(await page.locator('html').getAttribute('lang'), language);
+ await page.getByRole('button', { name: 'Switch to dark mode', exact: true }).filter({ visible: true }).click();
+ assert.ok(await page.locator('html').evaluate(node => node.classList.contains('dark')));
+ await page.getByRole('searchbox', { name: searchName, exact: true }).fill('San Ysidro');
+ await page.getByRole('button', { name: add, exact: true }).first().click();
+ await page.getByText(failedMessage, { exact: true }).waitFor();
+ assert.equal(await page.evaluate(() => window.resilienceEvents.filter(event => event.event === 'favorite-toggle').length), 0, 'failed persistence must not emit success');
+ await page.locator('#root a[href="/crossing/san-ysidro/"]').first().click();
+ await page.locator('#root').getByRole('link', { name: language === 'en' ? 'All crossings' : 'Todos los cruces', exact: true }).waitFor();
+ assert.equal(await page.locator('html').getAttribute('lang'), language);
+ await page.locator('#root a[href="/compare/san-ysidro-vs-otay-mesa/"]').first().click();
+ await page.getByTestId('compare-summary').waitFor();
+ await page.getByTestId('compare-summary').getByText(language === 'en' ? 'Reported standard-passenger waits' : 'Esperas reportadas, auto estándar', { exact: true }).waitFor();
+ assert.ok(!/Something went wrong/.test(await page.locator('#root').innerText()));
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ } finally { await page.close(); }
+ }
+
+ const page = await newPage();
+ try {
+ await page.addInitScript(() => {
+ if (sessionStorage.getItem('resilience-seeded')) return;
+ sessionStorage.setItem('resilience-seeded', 'true');
+ localStorage.setItem('borderPulse_favorites', '{}');
+ localStorage.setItem('borderPulse_language', 'invalid');
+ localStorage.setItem('borderPulse_theme', 'invalid');
+ localStorage.setItem('borderPulse_region', 'invalid');
+ });
+ await page.goto(baseUrl.href);
+ await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).waitFor();
+ assert.equal(await page.locator('html').getAttribute('lang'), 'en');
+ assert.ok(!await page.locator('html').evaluate(node => node.classList.contains('dark')));
+ if (language === 'es') await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
+ await page.getByRole('searchbox', { name: searchName, exact: true }).fill('San Ysidro');
+ await page.getByRole('button', { name: add, exact: true }).first().click();
+ assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('borderPulse_favorites'))), ['250401']);
+ await page.reload();
+ await page.getByRole('button', { name: language === 'en' ? 'Remove from favorites' : 'Quitar de favoritos', exact: true }).first().waitFor();
+
+ await page.goto(new URL('/crossing/san-ysidro/', baseUrl).href);
+ const crossingCanonical = 'https://borderpulse.com/crossing/san-ysidro/';
+ await page.waitForFunction(canonical => document.querySelector('link[rel="canonical"]')?.href === canonical, crossingCanonical);
+ assert.ok(await page.locator(`script[data-route-canonical="${crossingCanonical}"]`).count() > 0, 'direct entry retains its generated schema');
+ await page.locator('#root a[href="/compare/san-ysidro-vs-otay-mesa/"]').first().click();
+ await page.getByTestId('compare-summary').waitFor();
+ await page.waitForFunction(() => document.querySelector('link[rel="canonical"]')?.href.includes('/compare/'));
+ assert.equal(await page.locator(`script[data-route-canonical="${crossingCanonical}"]`).count(), 0, 'crossing schema must not leak into Compare');
+ for (const [twitter, og] of [['twitter:title', 'og:title'], ['twitter:description', 'og:description'], ['twitter:image', 'og:image']]) {
+ assert.equal(await page.locator(`meta[name="${twitter}"]`).getAttribute('content'), await page.locator(`meta[property="${og}"]`).getAttribute('content'));
+ }
+
+ await page.reload();
+ await page.getByTestId('compare-summary').waitFor();
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Enter');
+ assert.equal(await page.evaluate(() => document.activeElement.id), 'main-content', 'skip link moves keyboard focus into main');
+ if (page.viewportSize().width < 1024) {
+ const menu = page.getByRole('button', { name: 'Open menu', exact: true });
+ await menu.click();
+ assert.equal(await page.getByRole('button', { name: 'Close menu', exact: true }).getAttribute('aria-controls'), 'mobile-navigation');
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Escape');
+ assert.equal(await menu.getAttribute('aria-expanded'), 'false');
+ assert.ok(await menu.evaluate(node => node === document.activeElement));
+ }
+ for (const route of ['/compare/san-ysidro-vs-otay-mesa/', '/crossing/san-ysidro/', '/best-time/', '/best-time/san-ysidro/', '/blog/san-ysidro-vs-otay-mesa/', '/about/', '/methodology/', '/metodologia/']) {
+ await page.goto(new URL(route, baseUrl).href);
+ const back = page.locator('main').getByRole('link', { name: /^(All crossings|Todos los cruces|All posts|Todos los posts)$/ }).first();
+ await back.waitFor();
+ assert.equal(await page.locator('#root a button, #root button a').count(), 0, `${route}: navigation has one interactive element`);
+ assert.ok((await back.boundingBox()).height >= 44, `${route}: back link touch height`);
+ }
+ } finally { await page.close(); }
+ }
+ assert.deepEqual(errors, [], 'fixtures must not trigger an application error');
+ } finally {
+ await context.unroute(feedMatch, feedHandler);
+ await owner.evaluate(values => Object.entries(values).forEach(([key, value]) => value == null ? localStorage.removeItem(key) : localStorage.setItem(key, value)), saved);
+ }
+ console.log('[browser-resilience] PASS: EN/ES blocked reads/writes, malformed preferences, failed/successful favorites, in-page language, keyboard navigation and route metadata');
+}
diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs
index 6babc2836..3cb93b35c 100644
--- a/scripts/prerender.mjs
+++ b/scripts/prerender.mjs
@@ -297,7 +297,7 @@ function rewriteIndex(indexHtml, head) {
const jsonLdTags = head.jsonLd
.map((obj) => {
const body = JSON.stringify(obj).replace(/<\/script/gi, '<\\/script');
- return ` `;
+ return ` `;
})
.join('\n');
html = html.replace(/<\/head>/i, `${jsonLdTags}\n `);
diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs
index f2549be6c..17d595982 100644
--- a/scripts/smoke-routes.mjs
+++ b/scripts/smoke-routes.mjs
@@ -4,6 +4,7 @@ import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
import { checkGrowthInteractions } from './check-growth-interactions.mjs';
+import { checkBrowserResilience } from './check-browser-resilience.mjs';
import { checkLocalCrossings } from './check-local-crossings.mjs';
import { checkNoJsReports } from './check-nojs-reports.mjs';
import { checkWalkDriveFreshness } from './check-walk-drive-freshness.mjs';
@@ -321,6 +322,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) {
try {
await checkGrowthInteractions(page, context, baseUrl);
+ await checkBrowserResilience(page, context, baseUrl);
await checkLocalCrossings(page, context, baseUrl);
await checkWalkDriveAvailability(page, context, baseUrl);
await checkWalkDriveFreshness(context, baseUrl);
diff --git a/scripts/test-browser-state.mjs b/scripts/test-browser-state.mjs
new file mode 100644
index 000000000..70c74d016
--- /dev/null
+++ b/scripts/test-browser-state.mjs
@@ -0,0 +1,54 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readStorage, writeStorage, languageValue, themeValue, readFavorites } from '../src/lib/browserStorage.js';
+
+test('blocked storage getter does not prevent default preferences or empty favorites', () => {
+ const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
+ Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('blocked storage'); } });
+ try {
+ assert.equal(readStorage('borderPulse_language', 'en'), 'en');
+ assert.deepEqual(readFavorites(), []);
+ assert.equal(writeStorage('borderPulse_theme', 'dark'), false);
+ } finally {
+ if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
+ else delete globalThis.localStorage;
+ }
+});
+
+test('favorites accept only unique canonical port identifiers from a JSON array', () => {
+ const previous = globalThis.localStorage;
+ let value;
+ globalThis.localStorage = { getItem: () => value };
+ try {
+ for (value of ['{}', 'null', '42', '"250401"', 'invalid', null]) assert.deepEqual(readFavorites(), []);
+ value = '["250401",null,250601,{},"