From 61044e22a2ecaf9276cc4744e1895c3087b90ab2 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:01:40 +0900 Subject: [PATCH 1/2] History: Back and Forward land on the page the user left Each history entry now restores its own form and tab. The bare landing URL never shows results; Back to it restores the form as it was left and asks for Search, keeping a build made on the entry a refinement pushed off it. The saved-trips page has its own restorable entry, and Back restores the list's scroll after a drill-in. An open dialog owns one history entry: Back closes it, and closing it by its button pops that entry. "See all dates" opens both calendars in place, an empty Search adds no entry and shows a hint, the tab shortcuts no longer pull focus into an empty field, and a favorite opened from a tour page drops the tour's cities from the URL. --- docs/user-flows.md | 37 +++++-- scripts/e2e.mjs | 179 +++++++++++++++++++++++++++++++ src/app.ts | 237 +++++++++++++++++++++++++----------------- src/ui/modals.ts | 73 +++++++++++-- src/ui/render.ts | 2 - tests/history.test.ts | 81 +++++++++++++++ 6 files changed, 494 insertions(+), 115 deletions(-) create mode 100644 tests/history.test.ts diff --git a/docs/user-flows.md b/docs/user-flows.md index e28be9d..0dafd16 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -148,8 +148,9 @@ span applied to the lists only, and a day could read green while its list was em - **Trip modal** = booking recap: each leg's own **travel date** rides on the ticket header (beside "Outbound" / "Return"), and an unmistakable per-leg action — "Book the outbound" / "Book the return" (each deep-links SNCF Connect; a connecting leg opens the step modal) - — plus Save the whole trip. Back inside the accordion re-opens the outbound before it - exits the flow (step-wise back). + — plus Save the whole trip, and "See all dates", which closes the modal and opens both + legs and both calendars in place. Back inside the accordion re-opens the outbound before + it exits the flow (step-wise back). 3. **Only From, One-way** → browse (`runBrowse` "from"). Every destination reachable from the station, ranked by how well-served it is, with availability. Tap a card → the exact trip. The list reads direct cards, then connection-only ("via") rows, then the radius "Stations @@ -192,18 +193,35 @@ the destination is reachable, with its calendar open. 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 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. MAX SENIOR is weekday-only, so every calendar greys its weekends (a weekend list keeps its notice). - **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. -- **History model** — a genuine navigation (Search, drilling into a route, opening the saved - page) pushes **one** history entry carrying a form snapshot, so browser Back returns to the - prior page with the form intact. Refining the current view — the Aller simple/retour toggle, - the nights stepper, the Flexible pill, picking a calendar day — updates **in place** - (`replaceState`), never pushing a new entry. So repeated toggling can't pile up duplicate - entries (the old bug where Back needed ~10 presses and the form appeared wiped). +- **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, + never the latest one. Refining the current view — the Aller simple/retour toggle, the nights + stepper, the Flexible pill, picking a calendar day — updates **in place** (`replaceState`), + never pushing a new entry. So repeated toggling can't pile up duplicate entries (the old bug + where Back needed ~10 presses and the form appeared wiped). Details of the model: + - The **bare landing URL never shows results**: Back to it restores the form as it was left + and shows "press Search". A refinement made on the landing form still pushes one entry + (so there is a form to Back to), and since the user never left the form, Back to the + landing keeps what was built on that entry (a Flexible range included). + - The **saved-trips page** is its own entry: Forward onto it shows it again. + - Leaving a list for a route stamps the list's **scroll position**; Back restores it (the + drawer on a phone, the main column on a desktop). + - An open **dialog** owns one entry: Back closes it and leaves the page under it as it + was. Closing it with its own button, Escape or the backdrop pops that entry, so no stray + Back step is left behind. + - A **Search with nothing to search** (no station) adds no entry: a hint under the button + says what is missing. + - The `1/2/3` shortcuts never move focus into a field, so they keep working after landing + on an empty tab: an empty field gets a ready cursor only while nothing else holds focus, + as on the first load. - **Deep links** — every search is a shareable URL; legacy `?rdate=` / `?rt=` links still work. A station may be named in any case or accent (`from=paris`, `to=LILLE`) and resolves to the station that has trains; a name matching no station shows "Unknown station" instead @@ -219,4 +237,3 @@ the destination is reachable, with its calendar open. - Collapse the two save systems (favorite star + Save bookmark) into one. - One `openRoute()` primitive (list cards / favorites / map pins behave consistently). - One home for the availability calendar (form popover vs results). -- Mobile browser-Back should close detail pages via history. diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 4cb0e3f..a4662f8 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -557,6 +557,185 @@ await scenario( { viewport: { width: 390, height: 844, isMobile: true, hasTouch: true } }, ); +const MOBILE = { width: 390, height: 844, isMobile: true, hasTouch: true }; +const urlParam = (page, key) => new URL(page.url()).searchParams.get(key); +const fields = (page) => page.$$eval(".search-form .od-fields input", (els) => els.map((e) => e.value).join("|")); +async function fillOd(page, from, to) { + await page.evaluate( + (vals) => { + const inputs = document.querySelectorAll(".search-form .od-fields input"); + vals.forEach((v, i) => { + inputs[i].value = v; + inputs[i].dispatchEvent(new Event("input", { bubbles: true })); + inputs[i].dispatchEvent(new Event("change", { bubbles: true })); + }); + }, + [from, to], + ); +} +const clickSearch = (page) => page.click(".search-form .form-actions button.btn-primary"); + +// 16. Regression: each history entry restores its OWN form and tab. Search Paris → Lyon from +// the landing, open Ideas, Back twice: the landing comes back on the Trip tab with Paris → +// Lyon filled (not the Ideas form built after it), and its bare URL shows no results. +await scenario( + "history: Back to the landing restores its own form and tab, with no results under it", + BASE, + async (page) => { + await fillOd(page, P, L); + await clickSearch(page); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results"); + await page.click('.mode-tab[data-trip="ideas"]'); + assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open"); + await page.goBack(); + await page.goBack(); + assert(await until(async () => !new URL(page.url()).search), `two Backs did not reach the landing (${page.url()})`); + const landed = await until(async () => + (await activeTrip(page)) === "simple" && (await $count(page, ".results .empty")) > 0 ? true : null, + ); + assert(landed, `landing shows tab "${await activeTrip(page)}", results "${(await $text(page, ".results"))?.slice(0, 60)}"`); + const filled = await fields(page); + assert(/paris/i.test(filled) && /lyon/i.test(filled), `landing form is not its own (got "${filled}")`); + const rows = await $count(page, ".results .group-card, .results article.journey"); + assert(rows === 0, `the bare landing URL shows ${rows} result rows`); + }, +); + +// 17. Regression: the tab follows the entry, not the last form. Legs link → Ideas (no origin) +// → Multi-city → Back must land on the Ideas tab its URL names, without the legs results. +await scenario( + "history: Back to an Ideas entry shows the Ideas tab, not the Multi-city built after it", + `${BASE}?mode=tour&legs=${enc(`${P}>${L}@${DATE}`)}&date=${DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, ".mc-result")) > 0), "precondition: legs did not render"); + await page.click('.mode-tab[data-trip="ideas"]'); + assert(await until(async () => urlParam(page, "mode") === "best"), "precondition: Ideas did not open"); + await page.click('.mode-tab[data-trip="multi"]'); + assert(await until(async () => urlParam(page, "mode") === "tour"), "precondition: Multi-city did not open"); + await page.goBack(); + const back = await until(async () => + urlParam(page, "mode") === "best" && (await activeTrip(page)) === "ideas" && (await $count(page, ".mc-result")) === 0, + ); + assert(back, `Back to ?mode=best shows tab "${await activeTrip(page)}" with ${await $count(page, ".mc-result")} legs`); + }, +); + +// 18. Regression: the saved-trips page is its own history entry. See all → Back → Forward +// must land on the saved page again, not on the list under it. +await scenario( + "history: Forward returns to the saved-trips page", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, "article.journey button[aria-pressed]")) > 0), "precondition: no train to save"); + await page.click("article.journey button[aria-pressed]"); + await page.click(".saved-see-all"); + assert(await until(async () => (await $count(page, ".saved-page-card")) > 0), "the saved-trips page did not open"); + await page.goBack(); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "Back did not return to the list"); + await page.goForward(); + assert( + await until(async () => (await $count(page, ".saved-page-card")) > 0 && (await $count(page, "article.journey")) === 0), + `Forward shows "${await $text(page, "#results-title")}" instead of the saved-trips page`, + ); + }, +); + +// 19. Regression: a dialog owns one history entry. At 390px, Back with a dialog open closes +// it and stays on the results; a dialog closed by its own button leaves no stray entry, +// so the next Back reaches the form. +await scenario( + "history: Back closes an open dialog first; a dialog closed by its button leaves no entry", + BASE, + async (page) => { + await fillOd(page, P, L); + await clickSearch(page); + assert(await until(async () => (await $count(page, "article.journey")) > 0), "precondition: no Paris → Lyon results"); + const openDialog = async () => { + await page.keyboard.press("?"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "the help dialog did not open"); + }; + await openDialog(); + await page.goBack(); + assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "Back left the dialog open"); + assert(urlParam(page, "from"), "Back with a dialog open left the results page"); + assert((await $count(page, "article.journey")) > 0, "Back with a dialog open re-rendered the page empty"); + await openDialog(); + await page.click("dialog[open] .modal-close"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 0), "the Close button did not close the dialog"); + await page.goBack(); + assert(await until(async () => !urlParam(page, "from")), `one Back after closing did not reach the form (${page.url()})`); + }, + { viewport: MOBILE }, +); + +// 20. Regression: Back from a drilled-in route puts the list back where it was scrolled, in +// the drawer at 390px and in the main column at 1366px. +for (const [viewport, label] of [[MOBILE, "390px drawer"], [undefined, "1366px column"]]) { + await scenario( + `history: Back from a route restores the list's scroll (${label})`, + `${BASE}?mode=from&from=${enc(P)}&date=${DATE}`, + async (page) => { + assert(await until(async () => (await $count(page, ".results .group-card")) > 30), "precondition: short list"); + const scrollTop = () => + page.evaluate(() => { + const el = [".drawer-scroll", ".main-col"] + .map((s) => document.querySelector(s)) + .find((e) => e && /auto|scroll/.test(getComputedStyle(e).overflowY)); + return (el ?? document.scrollingElement).scrollTop; + }); + await page.evaluate(() => document.querySelectorAll(".results .group-card")[25].scrollIntoView()); + const before = await scrollTop(); + assert(before > 500, `precondition: the list did not scroll (${before}px)`); + await page.evaluate(() => { + const card = document.querySelectorAll(".results .group-card")[25]; + (card.querySelector(".dest-main") || card).click(); + }); + assert(await until(async () => (await $count(page, ".back-btn")) > 0), "the route did not open"); + await page.goBack(); + const restored = await until(async () => Math.abs((await scrollTop()) - before) <= 2); + assert(restored, `Back put the list at ${await scrollTop()}px, it was at ${before}px`); + }, + viewport && { viewport }, + ); +} + +// 21. Regression: "See all dates" in the trip dialog shows the dates in place — both +// calendars open, same page — instead of re-running the route as a new detail page. +await scenario( + "trip dialog: 'See all dates' opens the calendars in place, with no new page", + `${BASE}?mode=od&from=${enc(P)}&to=${enc(L)}&date=${RT_DATE}&rdate=${RT_DATE2}`, + async (page) => { + assert(await until(async () => page.$eval(".rt-view-ticket", (b) => !b.hidden).catch(() => false)), "precondition: no ticket"); + const url = page.url(); + await page.click(".rt-view-ticket"); + assert(await until(async () => (await $count(page, "dialog[open] .trip-more")) === 1), "the trip dialog did not open"); + await page.click("dialog[open] .trip-more"); + const opened = await until(async () => + (await $count(page, "dialog[open]")) === 0 && (await $count(page, '.results .cal-toggle[aria-expanded="true"]')) >= 2, + ); + assert(opened, `calendars open: ${await $count(page, '.results .cal-toggle[aria-expanded="true"]')} of 2`); + assert(page.url() === url, `the URL changed to ${page.url()}`); + assert((await $count(page, ".back-btn")) === 0, "'See all dates' opened a new detail page"); + }, +); + +// 22. Regression: the tab shortcuts keep working after one lands on an empty tab. Landing +// on the empty Trip tab must not pull focus into its field, where "3" would be typed. +await scenario( + "keyboard: 1/2/3 keep switching tabs after landing on an empty one", + `${BASE}?mode=tour&date=${DATE}`, + async (page) => { + await page.keyboard.press("1"); + const settled = await until(async () => (await activeTrip(page)) === "simple" && (await $count(page, ".results .loading")) === 0); + assert(settled, "'1' did not open the Trip tab"); + await page.keyboard.press("3"); + const ideas = await until(async () => (await activeTrip(page)) === "ideas"); + assert(ideas, `'3' did not open Ideas; the fields read "${await fields(page)}"`); + await page.keyboard.press("?"); + assert(await until(async () => (await $count(page, "dialog[open]")) === 1), "'?' did not open the shortcuts help"); + }, +); + // 12. PWA manifest is served and parseable, icon reference resolves. await scenario("pwa: manifest is served and valid JSON", BASE, async (page) => { const manifestHref = await page.$eval('link[rel="manifest"]', (el) => el.getAttribute("href")); diff --git a/src/app.ts b/src/app.ts index 4d1bd8c..1c01496 100644 --- a/src/app.ts +++ b/src/app.ts @@ -34,6 +34,7 @@ import { showMultiTripModal, showTourModal, showSettingsModal, + modalPopstate, } from "./ui/modals"; import { generateBookingUrl } from "./util/booking"; import { t, setLang, getLang, isLang } from "./i18n"; @@ -654,33 +655,21 @@ export function initApp(root: HTMLElement, dataset: Dataset, registry: StationRe // single back-stack; the entry's `detail` flag (read by renderSearch) keeps the in-app // "Retour" in step with where the history now sits. window.addEventListener("popstate", (ev) => { + if (modalPopstate(ev.state)) return; // Back closed a dialog; the page under it stays const searched = queryFromUrl(); - // Restore the FORM from the snapshot stashed on this history entry (staged edits — - // departure, destination, filters — survive the round trip), then the RESULTS from - // the URL. Falling back to the URL query keeps older entries (no snapshot) working. - const snap = formStateFrom(ev.state); - let formQuery = snap ?? searched; - // The URL decides the SCREEN: a renderable query is a COMMITTED search (results page); a - // bare, non-renderable URL is the home/form screen. Only the home entry can carry a - // snapshot frozen mid-build — stamped the instant "Aller-retour" was toggled (a same-day - // round trip), before Flexible + the return were picked — OR no snapshot at all. So ONLY - // there do we restore the full form the user last assembled, keeping the whole build - // (departure, Flexible range, filters) across a Back instead of a wiped/partial form. - // A committed search entry always keeps its OWN snapshot, so Backing through several - // distinct searches restores each one faithfully (never the latest build). goHome() nulls - // lastBuiltForm, so the logo/reset path lands on a genuinely empty home. - const onResults = queryIsRenderable(searched); - if (!onResults && lastBuiltForm && !isBlankForm(lastBuiltForm)) { - formQuery = lastBuiltForm; - } + const state = entryState(); + // Restore the FORM and tab from this entry's snapshot (the bare landing's is landingForm + // when set), then the RESULTS from the URL; an entry with no snapshot uses the URL. + const formQuery = (!store.urlHasQuery() && landingForm) || state.form || searched; + // The URL decides the SCREEN: a renderable query, or the saved-trips page, is a results + // page; anything else is the form screen, where renderSearch shows no results. + const onResults = queryIsRenderable(searched) || Boolean(state.saved); query = formQuery; syncFormFromQuery(); query = onResults ? searched : formQuery; - runSearch(); + runSearch(state.scroll); // On mobile the form and the results are two different screens. Back/Forward must - // move between them too: a URL with no search is the initial (form) screen, one - // with a search is the results screen. Follow the URL (onResults), not `query` — on the - // home entry `query` now carries the restored build, but the screen is still the form. + // move between them too, following the URL (onResults), not `query`. setMobileForm(!onResults); }); } @@ -897,7 +886,6 @@ function ctx(): RenderCtx { store.toggleTrip(buildSavedTrip(out, inb)); renderSavedTrips(); }, - onShowTrip: (out, inb) => showTripModal(out, ctx(), { inbound: inb, onShare: shareCurrentUrl }), isTourSaved: (tour) => store.isTripSaved(store.tourId(tour)), onToggleTour: (tour) => { store.toggleTrip(buildSavedTour(tour)); @@ -1147,34 +1135,41 @@ interface HistoryState { * the in-app "Retour" shows and Back returns to the underlying list. The browser history * is the single back-stack — this flag just marks which entries are drill-ins. */ detail?: boolean; + /** The saved-trips page: renderSearch shows it in place of the search under it. */ + saved?: boolean; + /** Pushed off the bare landing form by an in-place refinement (the trip-type toggle, the + * nights, Flexible): the user is still on the form, so it is the landing's form too. */ + landing?: boolean; + /** The list's scroll offset, stamped when a navigation leaves the entry. */ + scroll?: number; +} +/** The current history entry's state, empty on an entry the app never stamped. */ +function entryState(): Partial { + const s: unknown = history.state; + return s && typeof s === "object" ? (s as Partial) : {}; } /** Whether the current history entry is a drilled-in detail page. */ function currentDetail(): boolean { - const s = history.state; - return Boolean(s && typeof s === "object" && (s as { detail?: unknown }).detail); -} -// The last form the user actually built (origin/destination/legs filled). Kept so a Back -// that lands on the bare home entry — whose snapshot predates the finished build (e.g. it -// was stamped the moment Round trip was toggled, before Flexible + the return were picked) — -// restores the WHOLE form the user assembled instead of wiping it. "Keep all data of the -// initial form across every screen." -let lastBuiltForm: SearchQuery | null = null; -/** A form with no route yet — nothing worth preserving across a Back. */ -function isBlankForm(q: SearchQuery): boolean { - return !q.origin && !q.destination && !(q.legs && q.legs.length > 0); -} -function formSnapshot(detail = false): HistoryState { - const form = readQueryFromForm(); - if (!isBlankForm(form)) lastBuiltForm = form; // remember the richest form we've seen - return detail ? { form, detail: true } : { form }; -} -/** Read a form snapshot back off a popstate `event.state`, if one is present. */ -function formStateFrom(state: unknown): SearchQuery | null { - if (state && typeof state === "object" && "form" in state) { - const form = (state as { form?: unknown }).form; - if (form && typeof form === "object") return form as SearchQuery; + return Boolean(entryState().detail); +} +// The latest form of the entry pushed off the bare landing by an in-place refinement, which +// a Back to the landing restores; null when a Search or a drill-in left the landing instead. +let landingForm: SearchQuery | null = null; +/** Stamp the current entry with `query` and the live form, keeping its other flags. */ +function restamp(): void { + const state = { ...entryState(), form: readQueryFromForm() }; + if (state.landing) landingForm = state.form; + store.updateUrl(query, state); +} +/** Stamp the entry a navigation leaves: the list's scroll offset, which Back restores, and + * on the bare landing the form as the user left it. */ +function leaveEntry(): void { + const state = { ...entryState(), scroll: listScroller().scrollTop }; + if (!store.urlHasQuery()) { + state.form = readQueryFromForm(); + landingForm = null; } - return null; + history.replaceState(state, "", location.href); } /** Parse a day-count input into 1..14, falling back to `fallback`. */ @@ -1215,27 +1210,21 @@ function applyAndRun(push = true, detail = false): void { // replace in place instead of pushing a duplicate. const alreadyShown = store.urlHasQuery() && store.queryToParams(query).toString() === location.search.replace(/^\?/, ""); if ((push || leavingBareForm) && !alreadyShown) { - // If we're leaving the bare home/form page — no query in the URL and no form snapshot on - // the entry yet — stamp it (same URL, we only add state) with the staged form so a - // browser Back returns with the departure/destination/filters still filled instead of a - // wiped form ("even if you come back it gets deleted"). Guard on BOTH: an entry with a - // query in its URL owns a real page (a deep-linked or prior search) whose form Back must - // restore verbatim — stamping it with the form we're switching TO would corrupt it. The - // results entry pushed below carries its own snapshot for Forward. - if (leavingBareForm && !formStateFrom(history.state)) { - history.replaceState(formSnapshot(), "", location.href); - } + leaveEntry(); // Push a browser history entry so the native Back button returns to the prior page, // stashing a snapshot of the live form on the entry so a gesture-Back / popstate can // restore the exact form that produced this page instead of wiping it. `detail` marks a // drilled-in page (route from a list) so renderSearch shows the in-app Retour. - store.pushUrl(query, formSnapshot(detail)); + const form = readQueryFromForm(); + const landing = leavingBareForm && !push; + if (landing) landingForm = form; + store.pushUrl(query, { form, detail, landing }); } else { // In-place refinement of the view already on screen: REPLACE the current entry (still // stamping the live form snapshot, so Back restores the filled form) so a run of - // toggles adds zero history entries. Preserve the detail flag — a refine stays on the + // toggles adds zero history entries. The entry keeps its flags — a refine stays on the // same (possibly drilled-in) page. - store.updateUrl(query, formSnapshot(currentDetail())); + restamp(); } settings = { ...settings, card: query.card }; store.saveSettings(settings); @@ -1257,6 +1246,32 @@ function resultsScroller(): HTMLElement | null { return drawer && drawer.scrollHeight > drawer.clientHeight + 1 ? drawer : null; } +/** The element the result list scrolls in: the drawer on a phone, the main column on a + * desktop, else the page. */ +function listScroller(): Element { + const scrolls = (e: Element | null): e is Element => Boolean(e && /auto|scroll/.test(getComputedStyle(e).overflowY)); + return ( + [".drawer-scroll", ".main-col"].map((s) => document.querySelector(s)).find(scrolls) ?? + document.scrollingElement ?? + document.documentElement + ); +} + +/** Scroll the list to `top`, following it while its chunks render in, until it gets there + * or stops growing. */ +function scrollListTo(top: number): void { + const gen = renderGen; + const scroller = listScroller(); + let height = -1; + const step = (): void => { + if (gen !== renderGen || scroller.scrollHeight === height) return; + height = scroller.scrollHeight; + scroller.scrollTop = top; + if (scroller.scrollTop < top - 1) requestAnimationFrame(step); + }; + step(); +} + /** * Gently reveal an element that sits BELOW the current fold — and only then. A calendar * tap must never jerk the page/drawer UP (David: "clicking a date scrolls up, why?"), so @@ -1285,10 +1300,9 @@ function revealResults(): void { function refreshInPlace(reveal = false): void { // Restamp the entry with a FRESH form snapshot (not just the URL): an in-place refine — // completing a Flexible range, moving the return — changes the form, and a Back must - // restore that latest form, not the snapshot frozen before the refine. formSnapshot() - // also refreshes lastBuiltForm, so the home-entry fallback stays current. Preserve the - // detail flag: an in-place refresh (calendar day, moving the return) stays on the same page. - store.updateUrl(query, formSnapshot(currentDetail())); + // restore that latest form, not the snapshot frozen before the refine. The entry keeps + // its flags: an in-place refresh (calendar day, moving the return) stays on the same page. + restamp(); const scroller = resultsScroller(); const scrollY = scroller ? scroller.scrollTop : window.scrollY; // A calendar-day pick is usually what triggers an in-place refresh. If a day cell had @@ -1533,8 +1547,12 @@ function pickFormDay(date: string): void { * finished pick (`reveal`) scrolls the refreshed list into view when it sits below the fold. */ function commitFormPick(reveal: boolean): void { const fq = readQueryFromForm(); + // The bare landing shows no results to refresh, so a pick there is a navigation. const sameRoute = - query.origin === fq.origin && query.destination === fq.destination && (query.mode === "od" || tripIsRound()); + store.urlHasQuery() && + query.origin === fq.origin && + query.destination === fq.destination && + (query.mode === "od" || tripIsRound()); query = fq; // Run as soon as the query is searchable — an exact route OR a one-ended discovery // (origin-only "from"/"best", destination-only "to") — so tapping a day refreshes the @@ -1702,7 +1720,8 @@ function cancelLoading(): boolean { return true; } -function runSearch(): void { +/** Run the current query and render it; `scrollTo` puts the list back at that offset. */ +function runSearch(scrollTo?: number): void { searchToken++; const token = searchToken; searchLoading = true; @@ -1728,6 +1747,7 @@ function runSearch(): void { searchLoading = false; clear(refs.results); renderSearch(); + if (scrollTo !== undefined) scrollListTo(scrollTo); }); }); }; @@ -1819,9 +1839,18 @@ function appendInChunks( function renderSearch(): void { renderGen++; activeStepBack = null; // each render re-registers its own step-back (if any) + rootRef.dataset.detail = currentDetail() ? "on" : ""; + if (entryState().saved) return renderSavedPage(); + // A bare URL is the landing form: a filled form restored there waits for Search. + if (!store.urlHasQuery() && queryIsRenderable(query)) { + document.title = APP_TITLE; + refs.title.textContent = ""; + showSearchPrompt(); + updateSearchBar(); + return; + } const c = ctx(); updateDocTitle(); - rootRef.dataset.detail = currentDetail() ? "on" : ""; // NB: the map is drawn by exactly ONE call per render — the mode's own show()/ // route(), or showBaseMap() on an empty state (via showHint / a "nothing to plot" @@ -2128,9 +2157,7 @@ function runArmedPrompt(): void { refs.title.textContent = ""; refs.results.append(render.emptyEl(t("rt_need_origin"))); showBaseMap(); - // Focus the origin so typing one immediately runs discovery (no extra click) — but - // NOT on phones, where it springs the on-screen keyboard behind the results drawer. - if (!isTouch()) refs.origin.focus({ preventScroll: true }); + offerFocus(refs.origin); } function runMultiCity(c: RenderCtx): void { @@ -2757,9 +2784,16 @@ function runTripSearch(c: RenderCtx): void { b.body.style.display = collapsed ? "none" : ""; refreshSummary(i); }; + // "See all dates" in the ticket: both legs and both calendars open right here. + const showAllDates = (): void => { + setCollapsed(0, false); + setCollapsed(1, false); + outCalUI.setOpen(true); + retCalUI.setOpen(true); + }; const openTripModal = (): void => { if (chosenOutbound && boxes[1]?.chosen) { - showTripModal(chosenOutbound, c, { inbound: boxes[1].chosen, onShare: shareCurrentUrl }); + showTripModal(chosenOutbound, c, { inbound: boxes[1].chosen, onShare: shareCurrentUrl, onMoreDates: showAllDates }); } }; // Reopen the ticket on demand ("View ticket"): use the chosen legs when set, else the @@ -2768,7 +2802,7 @@ function runTripSearch(c: RenderCtx): void { const openTripModalBest = (): void => { const out = chosenOutbound ?? outJourneys[0]; const ret = boxes[1]?.chosen ?? returnJourneys(odReturnDate ?? proposed).list[0]; - if (out && ret) showTripModal(out, c, { inbound: ret, onShare: shareCurrentUrl }); + if (out && ret) showTripModal(out, c, { inbound: ret, onShare: shareCurrentUrl, onMoreDates: showAllDates }); }; const pickReturn = (j: Journey): void => { if (boxes[1]) boxes[1].chosen = j; @@ -2957,7 +2991,7 @@ function runTripSearch(c: RenderCtx): void { // a long pick shows the real fixed count). formApi.setStayNights(nights); } - store.updateUrl(query, formSnapshot(currentDetail())); + restamp(); paintReturn(retDate); deferFormCalRepaint(); // the form's range, header and stay grading follow the new return // The return list updates IN PLACE right where the calendar is — no scroll jump (a @@ -3218,9 +3252,13 @@ function showHint(input: HTMLInputElement): void { // empty — a confusing "why am I here?" page. Send the phone back to the search form // instead, which is the real entry point. setMobileForm(true); - // On phones, don't auto-focus the field: it pops the keyboard + the station - // suggestion dropdown over the whole UI on entry. Let the user tap it first. - if (!isTouch()) input.focus({ preventScroll: true }); + offerFocus(input); +} + +/** Put a ready cursor in the empty field while nothing holds focus (a navigation leaves it on + * the heading, where shortcuts work); never on a phone, where it pops the keyboard. */ +function offerFocus(input: HTMLInputElement): void { + if (!isTouch() && document.activeElement === document.body) input.focus({ preventScroll: true }); } function goBack(): void { @@ -3234,7 +3272,6 @@ function goBack(): void { /** Reset to the landing state (clicking the logo). Keeps language/theme/card. */ function goHome(): void { - lastBuiltForm = null; // an explicit reset — don't let a later Back resurrect the old form query = { mode: "from", date: today, card: settings.card, maxConnections: 1, hidden: true }; syncFormFromQuery(); applyAndRun(); @@ -3310,13 +3347,18 @@ function runFromForm(): void { setSurpriseMsg(t("err_station", { station: unknown.value.trim() })); return; } + const next = readQueryFromForm(); + // Nothing to search yet: name the missing step under Search, and leave the page and the + // history as they are rather than committing an empty search. + if (!queryIsRenderable(next)) { + const legs = tripType === "multi" && formApi.getMultiMode() === "legs"; + setSurpriseMsg(t(legs ? "multi_hint" : "need_origin")); + return; + } setSurpriseMsg(""); - query = readQueryFromForm(); + query = next; applyAndRun(); - // Only swap the phone to the results view when there's something real to show. An - // incomplete query stays on the form, with the missing step named under Search. - if (queryIsRenderable(query)) setMobileForm(false); - else setSurpriseMsg(t(query.mode === "tour" && formApi.getMultiMode() === "legs" ? "multi_hint" : "need_origin")); + setMobileForm(false); } /** Shift the chosen date by `delta` days, clamped to the bookable window. */ @@ -3947,7 +3989,9 @@ function fillRoute(origin: string, destination: string): void { // Clear any stale "via" so a saved route isn't filtered through an unrelated hub. query = { ...query, mode: "od", origin, destination, via: undefined }; syncFormFromQuery(); - store.updateUrl(query); // keep the URL in step with the prefilled route + // Re-read the Trip tab's form, which drops what only another tab carries (tour cities, legs). + query = readQueryFromForm(); + restamp(); // keep the URL and the entry's form in step with the prefilled route // Favorites live in the results drawer, but the form they prefill is a different // screen on mobile (display:none in results view). Bring the form sheet forward so // the prefilled route is actually visible — otherwise tapping a favorite did @@ -4014,7 +4058,12 @@ function savedTripInfo(trip: store.SavedTrip): { label: string; when: string; op return { label: `${deps.registry.label(out.origin)} ${inb ? "⇄" : "→"} ${deps.registry.label(out.destination)}`, when: inb ? `${formatDate(out.date)} – ${formatDate(inb.date)}` : formatDate(out.date), - open: () => showTripModal(out, ctx(), { inbound: inb, onShare: shareCurrentUrl }), + open: () => + showTripModal(out, ctx(), { + inbound: inb, + onShare: shareCurrentUrl, + onMoreDates: () => ctx().onOpenRoute(out.origin, out.destination), + }), }; } @@ -4072,18 +4121,14 @@ function renderSavedTrips(): void { /** Open the dedicated saved-trips page (full list), remembering where we were. */ function openSavedPage(): void { - // Push a browser history entry marked as a detail page (carrying the form snapshot) so a - // gesture / browser Back closes the saved page coherently — popping back to the underlying - // search — instead of skipping past it, and returns with the form intact. - store.pushUrl(query, formSnapshot(true)); - if (pendingRaf) cancelAnimationFrame(pendingRaf); - pendingRaf = 0; - // Enter the full-page detail layout (like drilling into a route) so this isn't - // crammed into the 30vh bottom sheet with the map behind it on mobile. On the way back, - // renderSearch reads the (now non-detail) entry and clears this. - rootRef.dataset.detail = "on"; - clear(refs.results); - renderSavedPage(); + // Push a detail entry flagged `saved`, carrying the form snapshot: Back returns to the + // search under it with the form intact, and renderSearch shows this page on Forward. + leaveEntry(); + store.pushUrl(query, { form: readQueryFromForm(), detail: true, saved: true }); + cancelLoading(); // a search still in flight must not paint over the page + // renderSearch enters the full-page detail layout (like drilling into a route) so this + // isn't crammed into the 30vh bottom sheet with the map behind it on mobile. + renderSearch(); refs.title.focus({ preventScroll: true }); refs.title.scrollIntoView({ behavior: "smooth", block: "start" }); } diff --git a/src/ui/modals.ts b/src/ui/modals.ts index 661541e..13a6253 100644 --- a/src/ui/modals.ts +++ b/src/ui/modals.ts @@ -6,18 +6,76 @@ import * as render from "./render"; import { t } from "../i18n"; import { APP_VERSION, APP_BUILD } from "../config"; +/* ── history ── */ + +// An open modal owns one history entry, the page's own state plus `dialog`, so the +// browser Back closes the modal instead of leaving the page under it. +let modalBase = ""; // the URL of the page under the modal +let popping = false; // the modal's own entry is being popped +let afterPop: (() => void) | null = null; + +function isModalEntry(state: unknown): boolean { + return Boolean(state && typeof state === "object" && (state as { dialog?: unknown }).dialog); +} + +function runAfterPop(): void { + const fn = afterPop; + afterPop = null; + fn?.(); +} + +/** Once the last open modal has closed, pop its entry, then run `afterPop`. */ +function releaseEntry(): void { + if (document.querySelector("dialog[open]")) return; // a modal reopened over it keeps the entry + if (isModalEntry(history.state)) { + popping = true; + history.back(); + } else { + runAfterPop(); + } +} + +/** + * Handle a popstate that belongs to a modal; true means the page under it stays as it is. + * Back closes an open modal, and Forward onto the entry of a closed one steps off it. + * @param state the popstate event's state. + */ +export function modalPopstate(state: unknown): boolean { + if (popping) { + popping = false; + runAfterPop(); + return true; + } + const open = document.querySelectorAll("dialog[open]"); + if (open.length > 0) { + for (const d of open) d.close(); + return location.href === modalBase; + } + if (!isModalEntry(state)) return false; + popping = true; + history.back(); + return true; +} + /* ── internal helpers ── */ /** * Wire the shared dialog lifecycle: remove from the DOM once closed, close on a - * backdrop click, then mount and open it. + * backdrop click, give it a history entry, then mount and open it. * @param dialog the dialog element to mount and open. */ function mountModal(dialog: HTMLDialogElement): void { - dialog.addEventListener("close", () => dialog.remove()); + dialog.addEventListener("close", () => { + dialog.remove(); + releaseEntry(); + }); dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); }); + if (!isModalEntry(history.state)) { + modalBase = location.href; + history.pushState({ ...history.state, dialog: true }, ""); + } document.body.append(dialog); dialog.showModal(); } @@ -191,16 +249,17 @@ export function showBookingModal(journey: Journey, ctx: RenderCtx): void { /** * The whole trip on one page: a single journey or a round trip, with both legs - * bookable, a share action, and a shortcut to the route's full calendar. Map - * actions are neutralised — there's no map behind the dialog to draw on. + * bookable, a share action, and a shortcut to the route's dates. Map actions are + * neutralised — there's no map behind the dialog to draw on. * @param outbound the outbound journey. * @param ctx render context for the trip card. - * @param opts optional inbound leg and a share handler. + * @param opts optional inbound leg, a share handler, and what "See all dates" does once + * the dialog and its history entry are gone. */ export function showTripModal( outbound: Journey, ctx: RenderCtx, - opts: { inbound?: Journey; onShare?: (onCopied: () => void) => void } = {}, + opts: { inbound?: Journey; onShare?: (onCopied: () => void) => void; onMoreDates: () => void }, ): void { const { inbound, onShare } = opts; const dialog = el("dialog", { class: "modal trip-modal" }) as HTMLDialogElement; @@ -210,8 +269,8 @@ export function showTripModal( text: t("trip_more_dates"), on: { click: () => { + afterPop = opts.onMoreDates; dialog.close(); - ctx.onOpenRoute(outbound.origin, outbound.destination); }, }, }); diff --git a/src/ui/render.ts b/src/ui/render.ts index ba20014..7b16e1d 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -42,8 +42,6 @@ export interface RenderCtx { isTripSaved: (outbound: Journey, inbound?: Journey) => boolean; /** Save the trip if absent, else remove it. */ onToggleTrip: (outbound: Journey, inbound?: Journey) => void; - /** Open the consolidated one-page view of a trip (round trip when `inbound` is set). */ - onShowTrip: (outbound: Journey, inbound?: Journey) => void; /** Whether this multi-city tour is saved. */ isTourSaved: (tour: Tour) => boolean; /** Save the tour if absent, else remove it. */ diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..312eb60 --- /dev/null +++ b/tests/history.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Leaflet needs a real browser canvas; stub the map module (as the smoke suite does). +vi.mock("../src/ui/map", () => ({ + RouteMap: class { + onSelect: ((id: string) => void) | null = null; + show(): void {} + route(): void {} + radius(): void {} + base(): void {} + highlight(): void {} + invalidate(): void {} + focus(): void {} + setInfo(): void {} + }, +})); + +import type { RawRecord, Station, DataMeta } from "../src/types"; +import { normalizeRecords } from "../src/data/dataset"; +import { StationRegistry } from "../src/data/stations"; +import { initApp } from "../src/app"; +import * as store from "../src/state/store"; +import sample from "../data/tgvmax.sample.json"; +import stations from "../data/stations.json"; + +const P = "PARIS (intramuros)"; +const L = "LYON (intramuros)"; +const enc = encodeURIComponent; +const meta: DataMeta = { updatedAt: "", source: "sample", recordCount: 0, isSample: true }; + +/** Mount the app on `search`; `seed` runs on the cleared storage before it loads. */ +function setup(search: string, seed?: () => void): HTMLElement { + localStorage.clear(); + seed?.(); + document.body.innerHTML = '
'; + const root = document.getElementById("app") as HTMLElement; + history.replaceState(null, "", `/${search}`); + initApp(root, { trains: normalizeRecords(sample as RawRecord[]), meta }, new StationRegistry(stations as Station[])); + return root; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-25T12:00:00Z")); + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof requestAnimationFrame; + Element.prototype.scrollIntoView = function scrollIntoView(): void {}; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("history and navigation", () => { + it("opens a favorite from a tour page with a clean Trip query (no cities= left over)", () => { + const root = setup(`?mode=tour&from=${enc(P)}&cities=${enc(L)}&date=2026-06-25`, () => + store.toggleFavorite({ origin: P, destination: L }), + ); + root.querySelector(".fav-row:not(.trip-row) .fav-open")!.click(); + const params = new URLSearchParams(location.search); + expect(params.get("mode")).toBe("od"); + expect(params.get("to")).toBe(L); + expect(params.has("cities")).toBe(false); + }); + + it("an empty Search adds no history entry and says what is missing", () => { + const root = setup(`?mode=od&from=${enc(P)}&to=${enc(L)}&date=2026-06-25`); + const [origin, destination] = root.querySelectorAll(".search-form .od-fields input"); + for (const input of [origin!, destination!]) { + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + } + const before = { length: history.length, search: location.search }; + root.querySelector(".search-form .form-actions button.btn-primary")!.click(); + expect(history.length).toBe(before.length); + expect(location.search).toBe(before.search); + expect(root.querySelector(".surprise-msg")?.textContent).not.toBe(""); + }); +}); From 3055285e5a32d782a25d539b737b8c6318dce84c Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:10:38 +0200 Subject: [PATCH 2/2] Mobile layout: no text is cut, low-end mode skips the map, saved trips open from the menu (#74) Via chips, the phone search bar, tour routes, leg names, airport names and the date pill wrap in full instead of ending in an ellipsis; the search bar takes two balanced lines and the map fit reserves its height. The prerendered page no longer carries the live map, so low-end mode downloads no map code and no tile. Fonts ship as files, which clears the 8 CSP errors every load logged. The phone header menu opens the saved page, favorites included, from the search form. The drawer measures its detents below the search bar, so the first half detent matches later ones and the full one stops under the bar; landing runs no view transition. Controls use logical sides in Arabic, and the dark theme sets color-scheme to dark. npm run verify now fails on CSP errors, map requests in low-end mode and cut text at 360, 390 and 1100px. --- docs/user-flows.md | 15 +++-- scripts/prerender.mjs | 5 ++ scripts/verify-render.mjs | 124 +++++++++++++++++++++++++++++++++++++- src/app.ts | 8 ++- src/i18n/ar.ts | 1 + src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 1 + src/i18n/it.ts | 1 + src/i18n/ja.ts | 1 + src/i18n/ko.ts | 1 + src/i18n/nl.ts | 1 + src/i18n/pt.ts | 1 + src/i18n/zh.ts | 1 + src/styles.css | 91 ++++++++++------------------ src/ui/icons.ts | 3 + src/ui/map.ts | 5 +- src/ui/shell.ts | 41 ++++++++----- vite.config.ts | 2 + 20 files changed, 222 insertions(+), 83 deletions(-) diff --git a/docs/user-flows.md b/docs/user-flows.md index fe989f2..7c7a9c5 100644 --- a/docs/user-flows.md +++ b/docs/user-flows.md @@ -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, diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 6f49bb7..244cf6f 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -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 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( @@ -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(/(]*?)\s+data-map="off"/, "$1") .replace(/(]*?)\s+style="[^"]*"/g, "$1") .replace(/(class="[^"]*?)\s+has-thumb\b/g, "$1") .replace(/(class="[^"]*?)\s+animate-thumb\b/g, "$1"); @@ -75,6 +79,7 @@ if (!html.includes(' 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(); @@ -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(); @@ -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."); diff --git a/src/app.ts b/src/app.ts index df4a40a..06a6efa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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), @@ -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 && diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index ea6a7c3..2e8a137 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -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: "لا توجد رحلات محفوظة بعد.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index df9fdc4..073eaba 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a1b6cee..7240a33 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index fa74da6..6ccfc0a 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 5cb492e..32e92dd 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -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.", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index eb5067a..5e150cd 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -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.", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 65c15e4..cc8943a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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: "保存した旅程はまだありません。", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 40c4d0a..c3dcd93 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -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: "아직 저장한 여행이 없습니다.", diff --git a/src/i18n/nl.ts b/src/i18n/nl.ts index 0a702d1..c113d4a 100644 --- a/src/i18n/nl.ts +++ b/src/i18n/nl.ts @@ -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.", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index a310c00..28d6fa4 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -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.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 730952b..30d4398 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -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: "暂无已保存行程。", diff --git a/src/styles.css b/src/styles.css index 23cd241..2d9264c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -34,7 +34,6 @@ font-synthesis: none; } -:root[data-theme="dark"], :root[data-theme="auto"] { color-scheme: light dark; } @@ -66,6 +65,7 @@ } } :root[data-theme="dark"] { + color-scheme: dark; --bg: #181613; --surface: #211f1b; --surface-2: #26241f; @@ -251,7 +251,11 @@ a { display: contents; } .install-btn .install-label { - margin-left: 0.4rem; + margin-inline-start: 0.4rem; +} +/* Phone menu only: on desktop the saved + favorites cards sit in the results column. */ +.saved-btn { + display: none; } .ctl { background: var(--surface); @@ -486,11 +490,11 @@ a { display: block; } .input.has-clear { - padding-right: 2rem; + padding-inline-end: 2rem; } .input-clear { position: absolute; - right: 0.45rem; + inset-inline-end: 0.45rem; top: 50%; transform: translateY(-50%); width: 1.4rem; @@ -560,7 +564,7 @@ a { /* A checkbox nested under another (e.g. "only night trains" under "night trains"): indented a little and with a subtler label, so it reads as a sub-option. */ .field-sub { - margin-left: 1.25rem; + margin-inline-start: 1.25rem; } .field-sub .field-label { color: var(--muted); @@ -1229,7 +1233,7 @@ a { /* "Only night trains" is a sub-option of "night trains", but side by side in the toggle band its indent would just read as a column out of alignment. */ .advanced-toggles .field-sub { - margin-left: 0; + margin-inline-start: 0; } .day-badge { font-size: 0.68rem; @@ -1440,7 +1444,8 @@ a { color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 0.2rem 1.5rem 0.2rem 0.5rem; + padding-block: 0.2rem; + padding-inline: 0.5rem 1.5rem; font: inherit; font-size: 0.82rem; cursor: pointer; @@ -1651,9 +1656,6 @@ a { .dest-name { font-weight: 600; font-size: 0.95rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; flex: 1 1 auto; min-width: 0; } @@ -1773,15 +1775,9 @@ a { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); - /* When the row is tight (long hub name / narrow screen) the chip yields - first: it shrinks and ellipsizes so the destination name keeps priority. */ - display: inline-block; + /* In a tight row the chip shrinks first and its hub names wrap in full. */ min-width: 0; - flex: 0 1 auto; - max-width: 14ch; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; line-height: 1.5; } .chip-soft { @@ -1972,13 +1968,10 @@ a { font-size: 0.93rem; flex-wrap: wrap; } -/* Let long station names shrink/ellipsize instead of overflowing the card on - narrow phones; the arrow icon, day-badge and airport flag keep their size. */ +/* Long station names shrink and wrap inside the card on narrow phones; the arrow + icon, day-badge and airport flag keep their size. */ .leg-route > span:not(.day-badge):not(.airport-badge) { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } /* A small ✈ marking an airport station, next to its name in lists and journeys. */ @@ -1994,7 +1987,7 @@ a { width: 13px; height: 13px; } -/* Airport name + flag: the name ellipsizes, the ✈ stays put (never clipped). */ +/* Airport name + flag: the name wraps, the ✈ stays beside it. */ .stn-airport { display: inline-flex; align-items: center; @@ -2002,9 +1995,6 @@ a { max-width: 100%; } .stn-airport .stn-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; min-width: 0; } .layover { @@ -2068,11 +2058,7 @@ a { row-gap: 0.3rem; } .dest-name { - flex: 1 1 auto; order: 1; - white-space: normal; - overflow: visible; - text-overflow: clip; } .dest-main > .chev { order: 2; @@ -2085,20 +2071,7 @@ a { } .dest-meta .stat-chip, .dest-meta .chip, - .dest-meta .chip-onsite, - .dest-meta .chip-via { - max-width: none; - overflow: visible; - text-overflow: clip; - white-space: normal; - } - /* Airport names keep their ✈ badge but the inner text wraps instead of ellipsizing. */ - .dest-name.stn-airport { - white-space: normal; - } - .dest-name .stn-text { - overflow: visible; - text-overflow: clip; + .dest-meta .chip-onsite { white-space: normal; } } @@ -2831,13 +2804,16 @@ a { select.input { appearance: none; -webkit-appearance: none; - padding-right: 2rem; + padding-inline-end: 2rem; background-image: var(--chev); background-repeat: no-repeat; background-position: right 0.6rem center; background-size: 13px; cursor: pointer; } +[dir="rtl"] select.input { + background-position: left 0.6rem center; +} select.input:hover { border-color: var(--border-strong); } @@ -3319,9 +3295,6 @@ select.input::-ms-expand { } .dest-main > .dest-body > .dest-name { flex: none; - white-space: normal; /* phones never truncate a name — a long one wraps in full */ - overflow: visible; - text-overflow: clip; } .dest-main > .dest-body > .dest-meta { flex: none; @@ -3588,9 +3561,7 @@ select.input::-ms-expand { } .dp-value-text { font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; } .dp-value-badge[hidden] { display: none; @@ -3845,10 +3816,7 @@ select.input::-ms-expand { font-family: var(--font-display); font-weight: 600; font-size: 1.02rem; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex: 1 1 0; /* fills the row beside the badge and date, wrapping a long route */ } .mc-date { margin-inline-start: auto; @@ -4275,9 +4243,7 @@ select.input::-ms-expand { } [data-mform="results"] .msearch-text { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-wrap: balance; /* a long route wraps to two even lines, not a lone "oct." */ } [data-mform="results"] .results-drawer { display: flex; @@ -4448,6 +4414,13 @@ select.input::-ms-expand { font-size: 1rem; font-weight: 600; } + .header-ctls .saved-btn { + display: flex; + align-items: center; + gap: 0.6rem; + padding-inline: 0.75rem; + text-align: start; + } .header-ctls .menu-actions .ctl { display: flex; align-items: center; diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 5c3c999..36b2321 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -23,6 +23,9 @@ export const LOGO_SVG = export const GITHUB_SVG = ''; +export const BOOKMARK_SVG = + ''; + export const SEARCH_SVG = ''; diff --git a/src/ui/map.ts b/src/ui/map.ts index 7cbd8b7..cc0bd5e 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -201,7 +201,10 @@ export class RouteMap { } const drawer = document.querySelector(".results-drawer"); const bottom = Math.round((drawer?.clientHeight ?? window.innerHeight * 0.3) + 12); - map.fitBounds(bounds, { paddingTopLeft: [24, 76], paddingBottomRight: [24, bottom] }); + // The bar wraps to two lines on a long route, so reserve its measured height. + const bar = document.querySelector(".msearch-bar"); + const top = bar ? bar.offsetTop + bar.offsetHeight + 17 : 76; + map.fitBounds(bounds, { paddingTopLeft: [24, top], paddingBottomRight: [24, bottom] }); } /** Render a hub station linked to each of `others`. Unknown coords are skipped. */ diff --git a/src/ui/shell.ts b/src/ui/shell.ts index e4e7b00..f8286b3 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -11,6 +11,7 @@ import { LOGO_SVG, GITHUB_SVG, SEARCH_SVG, + BOOKMARK_SVG, themeSvg, } from "./icons"; @@ -32,6 +33,7 @@ export interface ShellProps { onInstall: () => void; onShortcuts: () => void; onSettings: () => void; + onSaved: () => void; onOpenMobileForm: () => void; onSelect: (id: string) => void; onPeek: (id: string | null) => void; @@ -93,10 +95,10 @@ let teardownDrawer: (() => void) | null = null; * between peek / half / full detents. A no-op where matchMedia is unavailable. * @param drawer the drawer element to size. * @param handle the grab handle that drives the drag. - * @param mapSection the map behind the drawer, used to measure available height. - * @returns a cleanup that removes the media-query/resize listeners it installed. + * @param bar the floating search bar; the drawer's full height stops just below it. + * @returns a cleanup that removes the listeners and observer it installed. */ -function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLElement): () => void { +function setupDrawer(drawer: HTMLElement, handle: HTMLElement, bar: HTMLElement): () => void { const mq = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 860px)") : null; if (!mq) return () => {}; const order = ["peek", "half", "full"] as const; @@ -107,8 +109,7 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE let state: Detent = "half"; const sizes = (): Record => { - const mapTop = mapSection.getBoundingClientRect().top; - const full = Math.max(240, Math.round(window.innerHeight - mapTop - 6)); + const full = Math.max(240, Math.round(window.innerHeight - bar.getBoundingClientRect().bottom - 8)); const handleH = handle.offsetHeight || 46; return { peek: Math.max(handleH + 92, Math.round(full * 0.24)), @@ -120,7 +121,8 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE const snap = (s: Detent): void => { state = s; drawer.dataset.state = s; - if (mq.matches) drawer.style.height = `${sizes()[s]}px`; + // The bar only shows in the results view; measured while hidden, every detent is wrong. + if (mq.matches && bar.getClientRects().length) drawer.style.height = `${sizes()[s]}px`; }; let dragging = false; @@ -184,7 +186,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE const sync = (): void => { if (mq.matches) { + // A layout change resizes the sheet at once; only a tap or a drag animates it. + drawer.style.transition = "none"; snap(state); + void drawer.offsetHeight; + drawer.style.transition = ""; } else { drawer.style.height = ""; drawer.style.transition = ""; @@ -192,13 +198,11 @@ function setupDrawer(drawer: HTMLElement, handle: HTMLElement, mapSection: HTMLE }; mq.addEventListener("change", sync); window.addEventListener("resize", sync); - // The initial sync must wait until the layout is attached: buildShell runs before - // buildLayout appends the shell, so a synchronous measure here reads a detached - // mapSection (top = 0) and snaps to a too-tall drawer. Defer one frame so `full` - // is measured against the real viewport position. - if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => sync()); - else sync(); + // Re-measure each time the bar appears (entering the results view) or changes height. + const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => sync()) : null; + ro?.observe(bar); return () => { + ro?.disconnect(); mq.removeEventListener("change", sync); window.removeEventListener("resize", sync); window.removeEventListener("pointermove", onMove); @@ -291,6 +295,15 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML }); }); + // Saved trips + favorites live in the results column, which a phone hides on the + // form screen; this entry opens the saved page from anywhere. + const savedBtn = el("button", { + class: "ctl saved-btn", + type: "button", + html: `${BOOKMARK_SVG}${t("menu_saved")}`, + on: { click: () => props.onSaved() }, + }); + const ghLink = el("a", { class: "ctl icon-ctl gh-link", html: GITHUB_SVG, @@ -303,7 +316,7 @@ function buildHeader(props: ShellProps): { header: HTMLElement; cardSelect: HTML // theme, share, shortcuts — lives behind the hamburger to keep the mobile header compact. const quickCtls = el("div", { class: "header-quick" }, [cardSel, installBtn, settingsBtn]); const headerCtls = el("div", { class: "header-ctls" }, [ - el("div", { class: "menu-selects" }, [langSel]), + el("div", { class: "menu-selects" }, [langSel, savedBtn]), el("div", { class: "menu-actions" }, [ghLink, keysBtn, themeBtn, shareBtn]), ]); const menuBtn = el("button", { @@ -421,7 +434,7 @@ export function buildShell(props: ShellProps): ShellHandles { el("div", { class: "side-col" }, [mapSection]), ]); - teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, mapSection); + teardownDrawer = setupDrawer(resultsDrawer, drawerHandle, msearchBar); results.addEventListener("click", (ev) => { const card = (ev.target as HTMLElement).closest("[data-station]"); diff --git a/vite.config.ts b/vite.config.ts index 857bf53..56c2a8a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,6 +22,8 @@ export default defineConfig(({ command, mode }) => ({ build: { target: "es2022", sourcemap: true, + // Fonts always ship as files: the page CSP (font-src falls back to 'self') blocks data: fonts. + assetsInlineLimit: (file: string) => (/\.woff2?$/.test(file) ? false : undefined), }, test: { environment: "jsdom",