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",