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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# CHANGELOG

## 2026-11-10 - Spanish and accessibility polish (Phase 4, draft)

- Spanish: the mobile menu button is announced as "Abrir menú" / "Cerrar menú" instead of English. The blog index title, "Todos" tab, filter label and empty state follow the site language; all posts stay listed by default so Spanish readers can still open English guides. FAQ wording "job programado" is now "tarea programada".
- Accessibility (axe-core 4, WCAG 2.2 AA, 390px light mode): every audited page had two issue types, now zero on 14 pages in EN and ES (home, `/es/`, two crossing pages, best time, compare, walk or drive, lane flip, about, blog index, both medication guides, methodology, privacy).
- Contrast: `slate-500` small text measured 4.47:1 on the light page background. Light mode now uses #5e7088 (4.75:1); dark mode keeps the stock value. Ad labels and an About footnote moved from `slate-400` (2.46:1) to `slate-600`.
- Label in name: the EN/ES buttons are now announced "EN, Switch to English" and "ES, Cambiar a español", so the visible text is part of the accessible name. Browser checks updated to the new names.
- Checks: full `npm test` passes after a prerendered build. Entry bundle 62.00 KB gzip (budget 62.25).
- Not audited: dark mode contrast, screen-reader walkthroughs, physical devices.

## 2026-11-10 - Medication guide answers first (draft)

- The prescription-medication guide (EN and ES) now opens with its TL;DR, right after the intro and before the current-wait links. It was the last section. Wording unchanged, only moved. Umami, last 30 days: 95% bounce and a 21 s average visit, so most readers left before reaching it.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ If the user just says **"what's new"** or **"catch me up"** or
surfaced.
- Leave the working tree clean (`git status` shows nothing modified
except intentional artifacts).
- A local `npm run build` rewrites generated files under `public/`. Run
`git checkout -- public/` before committing unless you meant to edit one
(see DECISIONS.md 2026-11-10).

## Quick repo facts

Expand Down
8 changes: 8 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,3 +569,11 @@ The bundled `/data/crossings.json` only refreshes on Vercel deploys, so as a fal
## 2026-11-10 - Ads follow readers onto crossing pages and guides

Adsterra reported 21 impressions and $0.03 for Nov 5 to 9 from the single home-page slot, while 75% of visitors land on guides and crossing pages that loaded no ad. The same native placement now renders below the answer on crossing pages and guides; long guides can opt into one mid-article slot instead of the end slot. Never above the wait, never in embeds, one container per page. The dormant Google switch in `DashboardAd` still covers only the dashboard slot; these pages use `AdsterraBanner` directly.

## 2026-11-10 - Do not commit build-regenerated files from a local build

`npm run build` runs prebuild scripts that rewrite `public/` outputs (aggregates, rankings, stats, sitemap lastmod, RSS, OG cards, blog index). Every deploy regenerates them, and the data bot owns the data files, so commits from a local build only add noise (PRs #184-#187 included them by mistake; production was unaffected). Run `git checkout -- public/` before committing unless the change is meant to edit a `public/` file.

## 2026-11-10 - Light-mode slate-500 is one notch darker for AA contrast

Stock `slate-500` (#62748e) measured 4.47:1 on the light page background, just under 4.5:1, on every page. Light mode overrides `--color-slate-500` to #5e7088 (4.75:1) instead of editing hundreds of class names; `.dark` restores the stock value so dark mode does not lose contrast.
8 changes: 4 additions & 4 deletions scripts/check-browser-resilience.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function checkBrowserResilience(owner, context, baseUrl) {
else Storage.prototype.setItem = () => { throw new DOMException('Fixture quota', 'QuotaExceededError'); };
}, { failure, language });
await page.goto(new URL('/?display=list', baseUrl).href);
if (language === 'es') await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
if (language === 'es') await page.getByRole('button', { name: 'ES, 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();
Expand Down Expand Up @@ -75,7 +75,7 @@ export async function checkBrowserResilience(owner, context, baseUrl) {
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();
if (language === 'es') await page.getByRole('button', { name: 'ES, 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']);
Expand All @@ -100,9 +100,9 @@ export async function checkBrowserResilience(owner, context, baseUrl) {
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 });
const menu = page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ });
await menu.click();
assert.equal(await page.getByRole('button', { name: 'Close menu', exact: true }).getAttribute('aria-controls'), 'mobile-navigation');
assert.equal(await page.getByRole('button', { name: /^(Close menu|Cerrar menú)$/ }).getAttribute('aria-controls'), 'mobile-navigation');
await page.keyboard.press('Tab');
await page.keyboard.press('Escape');
assert.equal(await menu.getAttribute('aria-expanded'), 'false');
Expand Down
20 changes: 10 additions & 10 deletions scripts/check-crossing-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
await context.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin ? route.continue() : route.abort('blockedbyclient'));
const page = await context.newPage();
const enterFromNavigation = async (name) => {
if (viewport.width < 1024) await page.getByRole("button", { name: "Open menu", exact: true }).click();
if (viewport.width < 1024) await page.getByRole("button", { name: /^(Open menu|Abrir menú)$/ }).click();
const navigation = page.locator(viewport.width < 1024 ? "#mobile-navigation" : "aside");
const entry = navigation.getByRole("link", { name, exact: true });
assert.equal(await entry.count(), 1, "one region-qualified entry in the visible main navigation");
Expand Down Expand Up @@ -401,7 +401,7 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
await page.evaluate(() => document.activeElement?.textContent || ""),
/Sources/,
);
await page.getByRole("button", { name: "Cambiar a español" }).click();
await page.getByRole("button", { name: "ES, Cambiar a español" }).click();
assert.match(await page.locator("h1").innerText(), /Mapa de cruces San Ysidro–Otay/);
assert.equal(await page.locator("html").getAttribute("lang"), "es");
assert.match(await page.locator(".ocean").innerText(), /OCÉANO\s+PACÍFICO/);
Expand All @@ -421,9 +421,9 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
?.textContent.includes("desactualizada"),
);
await checkRetrievalWarningBlock(page, "ES stale", /^Consulta/, /desactualizada/);
await page.getByRole("button", { name: "Switch to English" }).click();
await page.getByRole("button", { name: "EN, Switch to English" }).click();
await checkRetrievalWarningBlock(page, "EN stale", /\bFetched\b/, /snapshot is stale/);
await page.getByRole("button", { name: "Cambiar a español" }).click();
await page.getByRole("button", { name: "ES, Cambiar a español" }).click();
assert.equal(await page.locator(".result-head").count(), 0);
await expectValues(["120", "60"]);
await page.clock.setSystemTime(clock);
Expand All @@ -434,9 +434,9 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
apiFallback = true;
await page.getByRole("button", { name: "Actualizar reportes", exact: true }).click();
await checkRetrievalWarningBlock(page, "ES bundled fallback", /^Consulta/, /Datos de respaldo/);
await page.getByRole("button", { name: "Switch to English" }).click();
await page.getByRole("button", { name: "EN, Switch to English" }).click();
await checkRetrievalWarningBlock(page, "EN bundled fallback", /\bFetched\b/, /Bundled fallback/);
await page.getByRole("button", { name: "Cambiar a español" }).click();
await page.getByRole("button", { name: "ES, Cambiar a español" }).click();
apiFallback = false;
}
failed = true;
Expand All @@ -457,7 +457,7 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
),
true,
);
await page.getByRole("button", { name: "Switch to English" }).click();
await page.getByRole("button", { name: "EN, Switch to English" }).click();
await checkRetrievalWarningBlock(page, "EN failed refresh", /\bFetched\b/, /Refresh unavailable/);
await page
.getByRole("link", { name: "All crossings", exact: true })
Expand All @@ -473,7 +473,7 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
await enterFromCrossing(slug, key, "San Ysidro–Otay map", "San Ysidro–Otay crossing map");
}
if (viewport.width < 1024) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByRole("button", { name: /^(Open menu|Abrir menú)$/ }).click();
const link = page.locator("#mobile-navigation").getByRole("link", { name: "San Diego map", exact: true });
assert.equal(await link.getAttribute("aria-current"), "page");
await link.click();
Expand All @@ -485,13 +485,13 @@ export async function checkCrossingConsole(browser, baseUrl, viewport) {
assert.match(await navLink.getAttribute("href"), /lane=passenger_ready/);
}
await page.goto(new URL("/?lane=passenger_ready", baseUrl).href);
await page.getByRole("button", { name: "Cambiar a español", exact: true }).click();
await page.getByRole("button", { name: "ES, Cambiar a español", exact: true }).click();
assert.equal(await page.locator('main a[href*="/corridor/"]').count(), 0, "Spanish dashboard also avoids duplicated map promotion");
await enterFromNavigation("Mapa San Diego");
await page.getByRole("heading", { name: "Mapa de cruces San Ysidro–Otay", exact: true }).waitFor();
assert.equal(new URL(page.url()).searchParams.get("lane"), "passenger_ready");
if (viewport.width < 1024) {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await page.getByRole("button", { name: /^(Open menu|Abrir menú)$/ }).click();
await page.locator("#mobile-navigation").getByRole("link", { name: "Mapa San Diego", exact: true }).waitFor();
await page.keyboard.press("Escape");
} else {
Expand Down
6 changes: 3 additions & 3 deletions scripts/check-document-language.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function checkDocumentLanguage(page, context, baseUrl) {
};
const click = route => page.locator(`#root a[href="${route}"]:visible`).first().click();
const switchTo = lang => page.getByRole('button', {
name: lang === 'es' ? 'Cambiar a español' : 'Switch to English', exact: true,
name: lang === 'es' ? 'ES, Cambiar a español' : 'EN, Switch to English', exact: true,
}).filter({ visible: true }).click();
page.setDefaultTimeout(5_000);
await context.route(feedMatch, feedHandler);
Expand Down Expand Up @@ -89,7 +89,7 @@ export async function checkDocumentLanguage(page, context, baseUrl) {
await inspect('/best-time/san-ysidro/', 'es');
await click('/');
await inspect('/', 'es');
const menu = page.getByRole('button', { name: 'Open menu', exact: true });
const menu = page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ });
if (await menu.isVisible()) await menu.click();
await click('/blog/');
await inspect('/blog/', 'es');
Expand Down Expand Up @@ -124,7 +124,7 @@ export async function checkDocumentLanguage(page, context, baseUrl) {
await inspect('/es/', 'es');
await page.locator('footer').getByRole('link', { name: 'Metodología', exact: true }).waitFor();
assert.equal(await page.locator('footer a[href="/methodology/"]').count(), 0);
const menu = page.getByRole('button', { name: 'Open menu', exact: true });
const menu = page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ });
if (await menu.isVisible()) await menu.click();
await page.getByRole('navigation', { name: 'Navegación principal', exact: true }).filter({ visible: true })
.getByRole('link', { name: 'Cruces', exact: true }).waitFor();
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-faq-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export async function checkFaqNavigation(page, baseUrl) {
await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).fill('San Ysidro');
await page.locator('#root a[href="/crossing/san-ysidro/"]:visible').first().click();
await inspect('/crossing/san-ysidro/');
const menu = page.getByRole('button', { name: 'Open menu', exact: true });
const menu = page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ });
if (await menu.isVisible()) await menu.click();
await page.getByRole('navigation', { name: 'Main navigation', exact: true }).getByRole('link', { name: 'Blog', exact: true }).click();
await page.getByRole('heading', { level: 1, name: 'Border Pulse Blog', exact: true }).waitFor();
Expand Down
12 changes: 6 additions & 6 deletions scripts/check-hreflang.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,28 @@ export async function checkHreflangNavigation(page, baseUrl) {
const homePair = pair('/', '/es/');
const click = async (route) => page.locator(`#root a[href="${route}"]:visible`).first().click();
await inspect('/', homePair);
await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
await page.getByRole('button', { name: 'ES, Cambiar a español', exact: true }).filter({ visible: true }).click();
await page.waitForFunction(() => document.documentElement.lang === 'es', null, { timeout: 10_000 });
await inspect('/', homePair); // Shared URL keeps this canonical. Spanish points at /es/.
await page.getByRole('button', { name: 'Switch to English', exact: true }).filter({ visible: true }).click();
await page.getByRole('button', { name: 'EN, Switch to English', exact: true }).filter({ visible: true }).click();
await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).fill('San Ysidro');
await click('/crossing/san-ysidro/');
await inspect('/crossing/san-ysidro/');
await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
await page.getByRole('button', { name: 'ES, Cambiar a español', exact: true }).filter({ visible: true }).click();
// A background crossing update can reset html.lang through the existing
// metadata cleanup. Verify the rendered language preference, independently
// of that separate lifecycle issue, then assert the full alternate set.
await page.locator('#root').getByRole('link', { name: 'Todos los cruces', exact: true }).waitFor({ timeout: 10_000 });
assert.equal(await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).getAttribute('aria-pressed'), 'true');
assert.equal(await page.getByRole('button', { name: 'ES, Cambiar a español', exact: true }).filter({ visible: true }).getAttribute('aria-pressed'), 'true');
await inspect('/crossing/san-ysidro/');
await page.getByRole('button', { name: 'Switch to English', exact: true }).filter({ visible: true }).click();
await page.getByRole('button', { name: 'EN, Switch to English', exact: true }).filter({ visible: true }).click();
await click('/best-time/san-ysidro/');
await inspect('/best-time/san-ysidro/');
await page.goBack();
await inspect('/crossing/san-ysidro/');
await click('/compare/san-ysidro-vs-otay-mesa/');
await inspect('/compare/san-ysidro-vs-otay-mesa/');
const menu = page.getByRole('button', { name: 'Open menu', exact: true });
const menu = page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ });
if (await menu.isVisible()) await menu.click();
await page.getByRole('navigation', { name: 'Main navigation', exact: true }).getByRole('link', { name: 'Blog', exact: true }).click();
await page.getByRole('heading', { level: 1, name: 'Border Pulse Blog', exact: true }).waitFor();
Expand Down
6 changes: 3 additions & 3 deletions scripts/check-lane-flips.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,11 @@ export async function checkLaneFlips(browser, baseUrl, viewport) {
assert.ok(await fits(page), 'detail overflows');

const navName = viewport.width < 1024 ? null : 'Main navigation';
if (viewport.width < 1024) await page.getByRole('button', { name: 'Open menu' }).click();
if (viewport.width < 1024) await page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ }).click();
const nav = navName ? page.getByRole('navigation', { name: navName }) : page.locator('#mobile-navigation');
await nav.getByRole('link', { name: 'Lane flips' }).waitFor();

await page.getByRole('button', { name: 'Cambiar a español' }).click();
await page.getByRole('button', { name: 'ES, Cambiar a español' }).click();
await page.getByRole('heading', { level: 1, name: 'Ready y SENTRI frente al carril general en Otay Mesa', exact: true }).waitFor();
assert.match(await page.getByTestId('verdict-passenger_ready').innerText(), /En la última consulta, Ready Lane se reporta 50 min más lento que el general/);
assert.match(await page.locator('#root').innerText(), /Revisa CBP antes de salir/);
Expand All @@ -205,7 +205,7 @@ export async function checkLaneFlips(browser, baseUrl, viewport) {
await verifyShare('es');
assert.ok(await fits(page), 'Spanish detail overflows');
if (viewport.width < 1024 && !(await page.locator('#mobile-navigation').isVisible())) {
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('button', { name: /^(Open menu|Abrir menú)$/ }).click();
}
const spanishNav = viewport.width < 1024
? page.locator('#mobile-navigation')
Expand Down
Loading
Loading