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
15 changes: 10 additions & 5 deletions docs/user-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,18 @@ the destination is reachable, with its calendar open.
- **Map** — full-bleed behind a results drawer on mobile, side panel on desktop. Markers per
destination, hover/selection synced with the list; route line for exact trips; auto-fits
above the drawer on mobile.
- **Saved & Favorites** — star a route / save a trip, from the header menu. (The two overlap
— a known cleanup item.) A saved trip opens its ticket modal, whose "See all dates" opens
the route page; a favorite prefills the Trip tab with that route alone.
- **Saved & Favorites** — star a route / save a trip. Both cards sit in the results column;
on a phone, where the search form hides that column, the header menu's "Saved trips &
favorites" entry opens the saved page (every saved trip, the favorites card below it)
from any screen. (The two overlap — a known cleanup item.) A saved trip opens its ticket
modal, whose "See all dates" opens the route page; a favorite prefills the Trip tab with
that route alone.
- **Settings** — theme, MAX Jeune/Senior, comfortable/compact, and Low-end mode (map off +
reduced motion + compact) with a one-time nudge on weak devices; language.
- **Mobile** — the form is a sheet that collapses to a search bar; results are a bottom-sheet
drawer with detents. Back navigation preserves form state and never lands on a dead screen.
- **Mobile** — the form is a sheet that collapses to a search bar (a long route wraps it to
two lines); results are a bottom-sheet drawer with peek / half / full detents measured
below that bar, so the full sheet never covers it. Back navigation preserves form state
and never lands on a dead screen.
- **History model** — a genuine navigation (Search, a tab switch, drilling into a route,
opening the saved page) pushes **one** history entry carrying a snapshot of the form, tab
included, so browser Back and Forward return to each page with **its own** form intact,
Expand Down
5 changes: 5 additions & 0 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, "language", { get: () => "fr-FR" });
Object.defineProperty(navigator, "languages", { get: () => ["fr-FR", "fr"] });
// Map off: the snapshot must not carry Leaflet's DOM, a tile <img> or the map chunk's
// preload links, or every visitor (low-end mode included) downloads them.
localStorage.setItem("mj.settings", JSON.stringify({ map: false }));
});
await page.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 });
await page.waitForFunction(
Expand All @@ -65,6 +68,7 @@ server.close();
// fallback background); hydration re-adds the pill, correctly placed for the real
// viewport, with no initial animation.
html = html
.replace(/(<html\b[^>]*?)\s+data-map="off"/, "$1")
.replace(/(<span class="mode-tab-thumb"[^>]*?)\s+style="[^"]*"/g, "$1")
.replace(/(class="[^"]*?)\s+has-thumb\b/g, "$1")
.replace(/(class="[^"]*?)\s+animate-thumb\b/g, "$1");
Expand All @@ -75,6 +79,7 @@ if (!html.includes('<html lang="fr"')) failures.push("prerendered HTML is not in
if (!html.includes('id="app"')) failures.push("prerendered HTML lost #app");
if (!html.includes("application/ld+json")) failures.push("prerendered HTML lost JSON-LD");
if (!html.includes("<noscript")) failures.push("prerendered HTML lost the noscript fallback");
if (/leaflet|assets\/map-/.test(html)) failures.push("prerendered HTML carries the map (Leaflet DOM or map chunk)");
if (failures.length) {
console.error("PRERENDER FAILED:");
for (const f of failures) console.error(" ✗ " + f);
Expand Down
124 changes: 121 additions & 3 deletions scripts/verify-render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* scripts/verify-render.mjs — deploy gate: prove the built app actually renders.
*
* Serves ./dist and loads it in headless Chromium (home + a deep-link). Fails
* (exit 1) if #app stays effectively empty or any uncaught page error fires, so
* a build that would show a blank page can never reach production.
* (exit 1) if #app stays effectively empty, any uncaught page error or CSP
* violation fires, low-end mode (map off) downloads map code or tiles, or any
* text is cut on a phone, so a build that would show a blank or broken page can
* never reach production.
*
* npm run build && npm run verify
*/
Expand Down Expand Up @@ -56,6 +58,9 @@ for (const { name, url } of pages) {
const page = await browser.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => {
if (m.type() === "error" && m.text().includes("Content Security Policy")) errors.push(m.text().slice(0, 160));
});
// Ignore cross-origin (map tile) failures — they're expected and harmless.
page.on("requestfailed", (r) => {
const u = r.url();
Expand All @@ -76,6 +81,119 @@ for (const { name, url } of pages) {
await page.close();
}

// Low-end mode must not fetch the map chunk or a tile; with the map on, Leaflet still mounts.
for (const map of [false, true]) {
const page = await browser.newPage();
await page.evaluateOnNewDocument((s) => localStorage.setItem("mj.settings", s), JSON.stringify({ map }));
const mapRequests = [];
page.on("request", (r) => {
if (/\/assets\/map-|tile\.openstreetmap/.test(r.url())) mapRequests.push(r.url());
});
await page.goto(pages[1].url, { waitUntil: "load", timeout: 45000 });
await page.waitForSelector(".results article.journey, .results .empty", { timeout: 45000 });
if (map) {
const mounted = await page.waitForSelector(".leaflet-container", { timeout: 20000 }).catch(() => null);
if (!mounted) failures.push("[map on] Leaflet never mounted");
} else {
await page.waitForNetworkIdle({ idleTime: 500, timeout: 20000 }).catch(() => {});
if (mapRequests.length) failures.push(`[map off] fetched map assets: ${mapRequests.join(", ")}`);
}
console.log(` map ${map ? "on" : "off"}: map requests=${mapRequests.length}`);
await page.close();
}

// Zero truncated text (product rule 2): every visible text node on these screens must fit,
// never ellipsized, line-clamped, clipped by an overflow:hidden box or pushed off screen.
const L = (id) => encodeURIComponent(id);
const CDG = "AEROPORT ROISSY CDG 2 TGV", VAL = "VALENCE TGV AUVERGNE RHONE ALPES", SPC = "ST PIERRE DES CORPS";
const DATE2 = new Date(Date.now() + 6 * 86_400_000).toISOString().slice(0, 10);
// The first day the snapshot runs both legs of a connection through `hub`, so connecting
// cards (via chips, long leg names) render; DATE when the data has none.
const snapshot = JSON.parse(readFileSync(join(DIST, "data", "tgvmax.json"), "utf-8"));
const runs = (o, d) =>
new Set(snapshot.filter((t) => t.origine === o && t.destination === d && t.date >= DATE).map((t) => t.date));
const viaDate = (o, hub, d) => [...runs(o, hub)].filter((day) => runs(hub, d).has(day)).sort()[0] ?? DATE;
const PARIS = "PARIS (intramuros)", LYON = "LYON (intramuros)", BDX = "BORDEAUX ST JEAN", TLS = "TOULOUSE MATABIAU";
const textPages = [
{
name: "exact-trip",
width: 390,
url: `${BASE}?mode=od&from=${P}&to=${T}&date=${viaDate(PARIS, BDX, TLS)}`,
must: ".msearch-text",
},
{
name: "long-names",
width: 360,
url: `${BASE}?mode=od&from=${L(CDG)}&to=${L(VAL)}&date=${viaDate(CDG, LYON, VAL)}`,
must: ".msearch-text",
},
{
name: "tour-legs",
width: 390,
url: `${BASE}?mode=tour&legs=${L(`${SPC}>${VAL}@${DATE}~${VAL}>${CDG}@${DATE2}`)}&date=${DATE}`,
must: ".mc-route",
},
// Desktop too: an airport name beside its ✈ badge was cut here.
{
name: "browse",
width: 1100,
url: `${BASE}?mode=from&from=${L(VAL)}&date=${[...runs(VAL, CDG)].sort()[0] ?? DATE}`,
must: ".stn-airport",
},
];
for (const { name, width, url, must } of textPages) {
const page = await browser.newPage();
await page.setViewport({ width, height: 844, isMobile: width < 861, hasTouch: width < 861 });
// Reduced motion: no view transition, so the screen is final once the results render.
await page.evaluateOnNewDocument(() => localStorage.setItem("mj.settings", '{"lang":"en","reduceMotion":true}'));
await page.goto(url, { waitUntil: "load", timeout: 45000 });
await page.waitForFunction(
(sel) =>
document.querySelector(sel)?.textContent.trim() &&
document.querySelector(".results")?.children.length &&
!document.querySelector(".results .loading"),
{ timeout: 45000 },
must,
).catch(() => failures.push(`[${name}@${width}] ${must} never rendered`));
const cut = await page.evaluate(() => {
const out = [];
for (const el of document.querySelectorAll("#app *")) {
const texts = [...el.childNodes].filter((c) => c.nodeType === 3 && c.textContent.trim());
if (!texts.length || el.closest(".sr-only, .leaflet-container, [aria-hidden='true'], select")) continue;
const cs = getComputedStyle(el);
const box = el.getBoundingClientRect();
if (cs.visibility !== "visible" || box.width < 2 || box.height < 2) continue; // hidden or visually hidden
const label = `${el.className || el.tagName} "${el.textContent.trim().slice(0, 40)}"`;
const overflows = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
if ((cs.textOverflow === "ellipsis" || cs.webkitLineClamp !== "none") && overflows) {
out.push(`${label} ellipsized`);
continue;
}
const range = document.createRange();
range.selectNodeContents(texts[0]);
const r = range.getBoundingClientRect();
if (r.right > document.documentElement.clientWidth + 1 || r.left < -1) {
out.push(`${label} off screen`);
continue;
}
for (let a = el; a && a.id !== "app"; a = a.parentElement) {
const o = getComputedStyle(a).overflowX;
if (o === "auto" || o === "scroll") break;
const ar = a.getBoundingClientRect();
if ((o === "hidden" || o === "clip") && (r.right > ar.right + 1 || r.left < ar.left - 1)) {
out.push(`${label} clipped`);
break;
}
}
}
return out;
});
if (cut.length) failures.push(`[${name}@${width}] cut text: ${cut.slice(0, 5).join(" | ")}`);
const vias = await page.$$eval(".results .chip-via", (n) => n.length);
console.log(` ${name}@${width}: cut text=${cut.length}, via chips=${vias}`);
await page.close();
}

await browser.close();
server.close();

Expand All @@ -84,4 +202,4 @@ if (failures.length) {
for (const f of failures) console.error(" ✗ " + f);
process.exit(1);
}
console.log("\nRender verification passed — the app mounts on every checked page.");
console.log("\nRender verification passed — the app mounts, low-end mode skips the map, no text is cut.");
8 changes: 7 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3865,6 +3865,11 @@ function buildLayout(root: HTMLElement): void {
onInstall: () => void promptInstall(),
onShortcuts: showShortcutsHelp,
onSettings: openSettings,
onSaved: () => {
closeHeaderMenu();
openSavedPage();
setMobileForm(false);
},
onOpenMobileForm: () => setMobileForm(true),
onSelect: (id) => markSelected(id),
onPeek: (id) => mapInstance?.peek(id),
Expand Down Expand Up @@ -3977,8 +3982,9 @@ function setMobileForm(open: boolean): void {
const mq = (q: string): boolean => typeof matchMedia === "function" && matchMedia(q).matches;
const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown };
// Morph the collapsed search bar into the full form (and back) on phones, via a
// shared view-transition-name; instant everywhere it isn't supported.
// shared view-transition-name; instant when the screen stays the same or it isn't supported.
if (
rootRef.dataset.mform !== (open ? "form" : "results") &&
mq("(max-width: 860px)") &&
!mq("(prefers-reduced-motion: reduce)") &&
!settings.reduceMotion &&
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const ar: Dict = {
act_saved: "محفوظ",
act_unsave: "إزالة من الرحلات المحفوظة",
saved_title: "الرحلات المحفوظة",
menu_saved: "الرحلات المحفوظة والمفضلة",
saved_see_all: "عرض الكل ({n})",
saved_count: "{n} رحلة محفوظة",
saved_none: "لا توجد رحلات محفوظة بعد.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const de: Dict = {
act_saved: "Gespeichert",
act_unsave: "Aus gespeicherten Reisen entfernen",
saved_title: "Gespeicherte Reisen",
menu_saved: "Gespeicherte Reisen & Favoriten",
saved_see_all: "Alle anzeigen ({n})",
saved_count: "{n} gespeicherte Reise(n)",
saved_none: "Noch keine gespeicherten Reisen.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export const en: Dict = {
act_saved: "Saved",
act_unsave: "Remove from saved trips",
saved_title: "Saved trips",
menu_saved: "Saved trips & favorites",
saved_see_all: "See all ({n})",
saved_count: "{n} saved trip(s)",
saved_none: "No saved trips yet.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const es: Dict = {
act_saved: "Guardado",
act_unsave: "Quitar de viajes guardados",
saved_title: "Viajes guardados",
menu_saved: "Viajes guardados y favoritos",
saved_see_all: "Ver todos ({n})",
saved_count: "{n} viaje(s) guardado(s)",
saved_none: "Aún no hay viajes guardados.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export const fr = {
act_saved: "Enregistré",
act_unsave: "Retirer des trajets enregistrés",
saved_title: "Trajets enregistrés",
menu_saved: "Trajets enregistrés et favoris",
saved_see_all: "Voir tout ({n})",
saved_count: "{n} trajet(s) enregistré(s)",
saved_none: "Aucun trajet enregistré pour l'instant.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const it: Dict = {
act_saved: "Salvato",
act_unsave: "Rimuovi dai viaggi salvati",
saved_title: "Viaggi salvati",
menu_saved: "Viaggi salvati e preferiti",
saved_see_all: "Mostra tutti ({n})",
saved_count: "{n} viaggio/i salvato/i",
saved_none: "Nessun viaggio salvato per ora.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const ja: Dict = {
act_saved: "保存済み",
act_unsave: "保存した旅程から削除",
saved_title: "保存した旅程",
menu_saved: "保存した旅程とお気に入り",
saved_see_all: "すべて表示 ({n})",
saved_count: "保存した旅程 {n} 件",
saved_none: "保存した旅程はまだありません。",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export const ko: Dict = {
act_saved: "저장됨",
act_unsave: "저장한 여행에서 제거",
saved_title: "저장한 여행",
menu_saved: "저장한 여행 및 즐겨찾기",
saved_see_all: "전체 보기 ({n})",
saved_count: "저장한 여행 {n}개",
saved_none: "아직 저장한 여행이 없습니다.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const nl: Dict = {
act_saved: "Opgeslagen",
act_unsave: "Verwijderen uit opgeslagen reizen",
saved_title: "Opgeslagen reizen",
menu_saved: "Opgeslagen reizen & favorieten",
saved_see_all: "Alles bekijken ({n})",
saved_count: "{n} opgeslagen reis(zen)",
saved_none: "Nog geen opgeslagen reizen.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const pt: Dict = {
act_saved: "Guardado",
act_unsave: "Remover das viagens guardadas",
saved_title: "Viagens guardadas",
menu_saved: "Viagens guardadas e favoritos",
saved_see_all: "Ver tudo ({n})",
saved_count: "{n} viagem(ns) guardada(s)",
saved_none: "Ainda não há viagens guardadas.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const zh: Dict = {
act_saved: "已保存",
act_unsave: "从已保存行程中移除",
saved_title: "已保存行程",
menu_saved: "已保存行程和收藏",
saved_see_all: "查看全部 ({n})",
saved_count: "{n} 个已保存行程",
saved_none: "暂无已保存行程。",
Expand Down
Loading
Loading