diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e98e1c5..e504c8082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a54f11e89..e2d425851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 9402167e5..840789c64 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/scripts/check-browser-resilience.mjs b/scripts/check-browser-resilience.mjs index 329d7732e..87d80fd9f 100644 --- a/scripts/check-browser-resilience.mjs +++ b/scripts/check-browser-resilience.mjs @@ -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(); @@ -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']); @@ -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'); diff --git a/scripts/check-crossing-console.mjs b/scripts/check-crossing-console.mjs index 32eaa0570..a42fabe97 100644 --- a/scripts/check-crossing-console.mjs +++ b/scripts/check-crossing-console.mjs @@ -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"); @@ -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/); @@ -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); @@ -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; @@ -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 }) @@ -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(); @@ -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 { diff --git a/scripts/check-document-language.mjs b/scripts/check-document-language.mjs index 042f4bfb5..ad1dc2430 100644 --- a/scripts/check-document-language.mjs +++ b/scripts/check-document-language.mjs @@ -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); @@ -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'); @@ -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(); diff --git a/scripts/check-faq-scope.mjs b/scripts/check-faq-scope.mjs index de9e05cb5..a280701af 100644 --- a/scripts/check-faq-scope.mjs +++ b/scripts/check-faq-scope.mjs @@ -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(); diff --git a/scripts/check-hreflang.mjs b/scripts/check-hreflang.mjs index 8e4c622ea..23c9279ab 100644 --- a/scripts/check-hreflang.mjs +++ b/scripts/check-hreflang.mjs @@ -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(); diff --git a/scripts/check-lane-flips.mjs b/scripts/check-lane-flips.mjs index 09bb45c4a..b7b253eb4 100644 --- a/scripts/check-lane-flips.mjs +++ b/scripts/check-lane-flips.mjs @@ -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/); @@ -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') diff --git a/src/Layout.jsx b/src/Layout.jsx index a62d3fd77..43006b332 100644 --- a/src/Layout.jsx +++ b/src/Layout.jsx @@ -124,13 +124,13 @@ export default function Layout({ children }) {
{language === 'en' ? 'Ads help support BorderPulse. Served by Adsterra.' : 'Los anuncios ayudan a mantener BorderPulse. Publicados por Adsterra.'}{' '} diff --git a/src/components/ads/GoogleAdBanner.jsx b/src/components/ads/GoogleAdBanner.jsx index 94294fc5a..09600a039 100644 --- a/src/components/ads/GoogleAdBanner.jsx +++ b/src/components/ads/GoogleAdBanner.jsx @@ -8,7 +8,7 @@ export default function GoogleAdBanner({ language = 'en' }) { const es = language === 'es'; return (