Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
15 changes: 15 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
11 changes: 11 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ 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. The isolated local commit and patch are ready for review;
publishing this follow-up needs separate owner approval.
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
Expand Down
26 changes: 26 additions & 0 deletions docs/REFRESH-OPERATIONS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
126 changes: 126 additions & 0 deletions scripts/check-browser-resilience.mjs
Original file line number Diff line number Diff line change
@@ -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');
}
2 changes: 1 addition & 1 deletion scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ function rewriteIndex(indexHtml, head) {
const jsonLdTags = head.jsonLd
.map((obj) => {
const body = JSON.stringify(obj).replace(/<\/script/gi, '<\\/script');
return ` <script type="application/ld+json">${body}</script>`;
return ` <script type="application/ld+json" data-route-canonical="${esc(head.canonical)}">${body}</script>`;
})
.join('\n');
html = html.replace(/<\/head>/i, `${jsonLdTags}\n </head>`);
Expand Down
2 changes: 2 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions scripts/test-browser-state.mjs
Original file line number Diff line number Diff line change
@@ -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,{},"<script>","250401","250601"]';
assert.deepEqual(readFavorites(), ['250401', '250601']);
} finally {
if (previous) globalThis.localStorage = previous;
else delete globalThis.localStorage;
}
});

test('preference values stay in the supported language and theme sets', () => {
assert.equal(languageValue('es'), 'es');
assert.equal(themeValue('dark'), 'dark');
for (const value of [null, undefined, '', {}, 'invalid']) {
assert.equal(languageValue(value), 'en');
assert.equal(themeValue(value), 'light');
}
});

test('write success is distinguishable from a quota failure without throwing', () => {
const previous = globalThis.localStorage;
const writes = [];
globalThis.localStorage = { setItem: (...args) => writes.push(args) };
try {
assert.equal(writeStorage('borderPulse_theme', 'dark'), true);
assert.deepEqual(writes, [['borderPulse_theme', 'dark']]);
globalThis.localStorage.setItem = () => { throw new Error('quota'); };
assert.equal(writeStorage('borderPulse_theme', 'light'), false);
} finally {
if (previous) globalThis.localStorage = previous;
else delete globalThis.localStorage;
}
});
Loading
Loading