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,{},"