From 70c30d32927f2f8844095840067b5ad4c821f00a Mon Sep 17 00:00:00 2001
From: Clement <159007607+csanchez-jetdev@users.noreply.github.com>
Date: Sat, 1 Aug 2026 16:34:50 +0200
Subject: [PATCH 1/6] =?UTF-8?q?fix:=20recherche=20invisible=20sur=20mobile?=
=?UTF-8?q?,=20et=20remont=C3=A9e=20au-dessus=20de=20la=20carte?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Le repli du formulaire en résumé se déclenchait sur tout changement du
résultat d'itinéraire. `useItinerary` étant `server: false`, celui-ci passe
de `undefined` à `null` au montage : sur écran étroit le formulaire se
repliait aussitôt, sans résumé à afficher puisqu'aucune recherche n'avait
eu lieu — plus rien n'était visible ni cliquable. Le repli exige désormais
un résultat, et son état dérive de la présence d'un résumé.
Le corps de l'application passe en grille : recherche, carte puis résultats
sur écran étroit, colonne recherche + résultats à gauche et carte à droite
sur desktop. La carte mobile descend de 38vh à 32dvh.
Lien vers le dépôt dans l'en-tête.
---
app/components/GithubLink.vue | 28 +++++++++++
app/pages/app.vue | 89 ++++++++++++++++++++++-------------
2 files changed, 83 insertions(+), 34 deletions(-)
create mode 100644 app/components/GithubLink.vue
diff --git a/app/components/GithubLink.vue b/app/components/GithubLink.vue
new file mode 100644
index 0000000..0c7a59e
--- /dev/null
+++ b/app/components/GithubLink.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ {{ label }}
+
+
diff --git a/app/pages/app.vue b/app/pages/app.vue
index ea12357..454258f 100644
--- a/app/pages/app.vue
+++ b/app/pages/app.vue
@@ -52,8 +52,11 @@ onMounted(() => {
isNarrow.value = mq.matches
mq.addEventListener('change', (e) => (isNarrow.value = e.matches))
})
-watch([result, () => itinerary.route.value], () => {
- if (isNarrow.value) formOpen.value = false
+// Ne replier que sur une recherche qui a effectivement abouti : `useItinerary` étant
+// `server: false`, son résultat passe de `undefined` à `null` au montage, et ce seul
+// changement suffisait à replier un formulaire qui n'avait encore rien à résumer.
+watch([result, () => itinerary.route.value], ([found, foundRoute]) => {
+ if (isNarrow.value && (found || foundRoute)) formOpen.value = false
})
function onSearch(params: Parameters[0]) {
search(params)
@@ -75,6 +78,12 @@ const summary = computed(() => {
return `${where} · ${when}`
})
+/**
+ * État replié, dérivé plutôt que déclaré : il exige un résumé à afficher, ce qui garantit
+ * qu'un des deux affichages est toujours visible et jamais une colonne de recherche vide.
+ */
+const collapsed = computed(() => isNarrow.value && !formOpen.value && Boolean(summary.value))
+
async function onShowReturns(destLabel: string) {
if (!result.value) return
await loadReturns(destLabel, result.value.origin.label, result.value.date)
@@ -119,12 +128,50 @@ useHead({
Trainquillou
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
From b670a37bc93ce5a432e54396c3b2f527788b2563 Mon Sep 17 00:00:00 2001
From: Clement <159007607+csanchez-jetdev@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:43:52 +0200
Subject: [PATCH 3/6] =?UTF-8?q?feat:=20couleur=20de=20marque=20sur=20les?=
=?UTF-8?q?=20boutons=20de=20r=C3=A9servation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Les deux liens sortants de la fiche de destination deviennent deux boutons
de largeur égale, chacun dans la couleur de son revendeur : bleu nuit #0c131f
pour SNCF Connect, vert #00705f pour Trainline. Les valeurs sont relevées sur
les styles calculés de leurs propres pages.
Trainline utilise le vert clair de son logo (#00a88f) sous du texte blanc,
soit 3:1 — sous le seuil AA. C'est leur vert foncé de bouton qui est retenu,
à 6:1.
---
app/assets/css/main.css | 13 ++++++++
app/components/DestinationPopover.vue | 46 ++++++++++++++++-----------
2 files changed, 40 insertions(+), 19 deletions(-)
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 0ffb543..6cb12d9 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -15,6 +15,19 @@
/* Fond crème */
--color-cream: #fafaf7;
+ /*
+ * Couleurs des deux revendeurs, relevées sur leurs propres pages (styles calculés,
+ * pas une palette de seconde main) : reconnaître la destination d'un lien sortant
+ * avant de cliquer vaut mieux que deux liens soulignés identiques.
+ *
+ * `trainline` est le vert foncé de leurs propres boutons, pas le vert clair de leur
+ * logo (#00a88f) : celui-ci ne donne que 3:1 avec du texte blanc, sous le seuil AA.
+ */
+ --color-sncf: #0c131f;
+ --color-sncf-hover: #242a3e;
+ --color-trainline: #00705f;
+ --color-trainline-hover: #00a88f;
+
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
diff --git a/app/components/DestinationPopover.vue b/app/components/DestinationPopover.vue
index 37ffa73..b899750 100644
--- a/app/components/DestinationPopover.vue
+++ b/app/components/DestinationPopover.vue
@@ -190,25 +190,33 @@ function formatDate(iso: string): string {
From c242cc657db574fcba1682ed854afd7168606380 Mon Sep 17 00:00:00 2001
From: Clement <159007607+csanchez-jetdev@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:43:52 +0200
Subject: [PATCH 4/6] =?UTF-8?q?chore:=20serveur=20de=20d=C3=A9veloppement?=
=?UTF-8?q?=20sur=20le=20port=203001?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
3000 est occupé par d'autres services sur la machine de développement.
Sans port fixe, Nuxt en choisit un libre au hasard et les URL changent
d'une session à l'autre. La production n'est pas concernée : son port
vient de infra/compose.yml.
---
README.md | 4 ++--
nuxt.config.ts | 7 +++++++
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index cb39b67..85ffdd4 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ Prérequis : **Node 20+** et **pnpm**.
git clone https://github.com/csanchez-jetdev/trainquillou.git
cd trainquillou
pnpm install
-pnpm dev # http://localhost:3000
+pnpm dev # http://localhost:3001
```
Aucune clé d'API, aucun compte, aucun fichier `.env` : l'open data SNCF est ouvert et le
@@ -170,7 +170,7 @@ format stable. Elles sont utilisables telles quelles si vous auto-hébergez.
exigent `dateTo`.
```bash
-curl 'http://localhost:3000/api/search?origin=LYON%20(intramuros)&date=2026-08-14'
+curl 'http://localhost:3001/api/search?origin=LYON%20(intramuros)&date=2026-08-14'
```
## Architecture
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6ae31d8..ab9fbe7 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -32,6 +32,13 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
+
+ // 3000 est le port par défaut de trop de choses — dont Langfuse, qui tourne en
+ // conteneur sur la machine de développement. Sans port fixe, Nuxt en choisit un
+ // libre au hasard et les URL de test changent d'une session à l'autre.
+ // Surchargeable par `PORT` ou `pnpm dev --port`. Ne concerne pas la production,
+ // dont le port est fixé par infra/compose.yml.
+ devServer: { port: 3001 },
vite: {
plugins: [tailwindcss()],
optimizeDeps: {
From 1d585b23bcc509732772c047da306fd9f018c5a6 Mon Sep 17 00:00:00 2001
From: Clement <159007607+csanchez-jetdev@users.noreply.github.com>
Date: Sat, 1 Aug 2026 18:23:54 +0200
Subject: [PATCH 5/6] feat: bascule carte / liste sur mobile
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Carte et liste occupent la même cellule de grille sur écran étroit, une bascule
flottante décide laquelle est devant. Taper une destination dans la liste passe
sur la carte, où sa fiche s'ancre.
La carte reste dimensionnée sous la liste plutôt que masquée : son cadrage se
calcule sur la taille du conteneur.
Le cadrage remplace ses 400 px de marge à gauche par une fraction du conteneur.
Sur un écran de 393 px, cette marge donnait une largeur utile négative et
sortait la moitié des marqueurs du cadre.
Le suivi de taille de la carte passe de MapLibre à un ResizeObserver local :
le sien écarte sa première notification, qui absorbait ici le repli du
formulaire et figeait le canevas aux deux tiers de sa ligne.
---
app/components/MapView.client.vue | 46 ++++++++-
app/components/ResultsRail.vue | 7 +-
app/components/RoutePanel.vue | 4 +-
app/pages/app.vue | 158 +++++++++++++++++++++++++-----
4 files changed, 186 insertions(+), 29 deletions(-)
diff --git a/app/components/MapView.client.vue b/app/components/MapView.client.vue
index b8a0d9b..f49695f 100644
--- a/app/components/MapView.client.vue
+++ b/app/components/MapView.client.vue
@@ -48,6 +48,7 @@ const TINTS: Array<{ id: string; prop: string; color: string }> = [
const instance = getCurrentInstance()
let map: maplibregl.Map | null = null
+let sizeObserver: ResizeObserver | null = null
/**
* Le style est chargé et les couches peuvent être ajoutées. On ne peut pas se fier à
* `map.loaded()` : il renvoie `false` pendant une animation de caméra, et un
@@ -160,7 +161,28 @@ onMounted(async () => {
// L'attribution (OpenFreeMap, OpenMapTiles, OpenStreetMap) vient du TileJSON de
// la source : la déclarer ici la ferait apparaître en double.
attributionControl: { compact: true },
+ // Le suivi de taille est fait ici, voir plus bas.
+ trackResize: false,
})
+
+ /**
+ * Suivi de la taille du conteneur, à la place de celui de MapLibre. Le sien écarte sa
+ * première notification — celle que `ResizeObserver` émet toujours à l'abonnement —
+ * pour ne pas refaire le calcul du constructeur. Mais si la mise en page bouge dans la
+ * même frame que la création de la carte, les deux tailles se fondent en une seule
+ * notification, celle-ci est écartée, et le canevas reste figé à sa taille de départ
+ * pour de bon. C'est le cas ici dès qu'une recherche aboutit : elle replie le
+ * formulaire, la ligne de la carte grandit d'autant, et la carte s'arrêtait aux deux
+ * tiers de sa ligne sur mobile.
+ */
+ let lastSize = ''
+ sizeObserver = new ResizeObserver(() => {
+ const size = `${container.clientWidth}×${container.clientHeight}`
+ if (size === lastSize) return
+ lastSize = size
+ map?.resize()
+ })
+ sizeObserver.observe(container)
// Render initial state once map tiles are ready
map.once('load', () => {
styleReady = true
@@ -177,6 +199,8 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
+ sizeObserver?.disconnect()
+ sizeObserver = null
map?.remove()
map = null
})
@@ -192,6 +216,24 @@ function removeLayerSource(id: string) {
if (map.getSource(id)) map.removeSource(id)
}
+/**
+ * Marge du cadrage, en pixels. Elle était fixée à 400 px à gauche, du temps où la carte
+ * passait en pleine largeur sous la colonne de recherche ; celle-ci occupe désormais sa
+ * propre colonne de grille et cette marge ne faisait plus que tasser les points contre le
+ * bord droit. Sur un téléphone elle était fatale : 400 px de marge dans 393 px de large
+ * donnent une largeur utile négative, et le cadrage sortait la moitié des marqueurs du
+ * cadre — dont la gare de départ.
+ *
+ * Donc une fraction du conteneur, plafonnée : une marge doit aérer le cadrage, pas le
+ * manger, et elle ne peut jamais dépasser ce qu'elle borde.
+ */
+function fitPadding(): maplibregl.PaddingOptions {
+ const el = map?.getContainer()
+ const x = Math.min(60, Math.round((el?.clientWidth ?? 480) / 8))
+ const y = Math.min(60, Math.round((el?.clientHeight ?? 480) / 8))
+ return { top: y, bottom: y, left: x, right: x }
+}
+
/** Crée un élément DOM de marqueur (pastille colorée). */
function dot(color: string, size: number): HTMLDivElement {
const el = document.createElement('div')
@@ -245,7 +287,7 @@ function renderRoute(route: RouteResult, selected: number) {
if (pts.length > 1) {
const bounds = new maplibregl.LngLatBounds()
pts.forEach((p) => bounds.extend([p[1], p[0]]))
- map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 })
+ map.fitBounds(bounds, { padding: fitPadding(), maxZoom: 8, duration: 800 })
}
}
@@ -328,7 +370,7 @@ function render(result: SearchResult | null | undefined) {
if (pts.length > 1) {
const b = new maplibregl.LngLatBounds()
pts.forEach((p) => b.extend([p[1], p[0]]))
- map.fitBounds(b, { padding: { top: 60, bottom: 60, left: 400, right: 60 }, maxZoom: 8, duration: 800 })
+ map.fitBounds(b, { padding: fitPadding(), maxZoom: 8, duration: 800 })
}
}
diff --git a/app/components/ResultsRail.vue b/app/components/ResultsRail.vue
index 4dc187c..8fa8a70 100644
--- a/app/components/ResultsRail.vue
+++ b/app/components/ResultsRail.vue
@@ -201,8 +201,11 @@ const CHIP_OFF = 'border-slate-200 bg-white text-rail-soft hover:border-slate-30
-
+ échelle. La marge négative annule le padding de la colonne.
+ `pb-14` sur mobile : de quoi faire défiler la dernière destination au-dessus de
+ la bascule carte / liste, qui flotte par-dessus le bas de la liste. Du défilement
+ en plus, pas de la hauteur utile en moins. -->
+
-
+
+
(null)
@@ -47,10 +47,27 @@ const returnsByDest = computed(() => {
*/
const isNarrow = ref(false)
const formOpen = ref(true)
+
+/**
+ * Sur un écran étroit, carte et liste ne tiennent pas ensemble : la carte réduite au
+ * tiers de la hauteur ne séparait pas les marqueurs franciliens, et les trois
+ * destinations qui restaient visibles ne faisaient pas une liste. On en montre donc
+ * une seule à la fois, au choix.
+ */
+const mobileView = ref<'map' | 'list'>('list')
+const MOBILE_VIEWS = [
+ { key: 'map', label: 'Carte', icon: 'M12 21c4-4.6 6-7.8 6-10.5a6 6 0 1 0-12 0C6 13.2 8 16.4 12 21Zm0-9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' },
+ { key: 'list', label: 'Liste', icon: 'M4 6h16M4 12h16M4 18h16' },
+] as const
+
onMounted(() => {
const mq = window.matchMedia('(max-width: 767px)')
isNarrow.value = mq.matches
mq.addEventListener('change', (e) => (isNarrow.value = e.matches))
+ // Sans recherche en cours, la liste n'a qu'une phrase à afficher : la carte fait un
+ // meilleur écran d'accueil. Avec une recherche — lien partagé, retour navigateur —
+ // les résultats arrivent, autant ouvrir là où ils vont s'afficher.
+ mobileView.value = hasQuery.value ? 'list' : 'map'
})
// Ne replier que sur une recherche qui a effectivement abouti : `useItinerary` étant
// `server: false`, son résultat passe de `undefined` à `null` au montage, et ce seul
@@ -60,7 +77,36 @@ watch([result, () => itinerary.route.value], ([found, foundRoute]) => {
})
function onSearch(params: Parameters[0]) {
search(params)
- if (isNarrow.value) formOpen.value = false
+ if (!isNarrow.value) return
+ formOpen.value = false
+ // Vers la liste : c'est elle qui porte le squelette de chargement, le message d'erreur
+ // et son bouton de reprise, le décompte, le tri et les filtres. Rester sur la carte
+ // laisserait la recherche sans retour visible pendant qu'elle tourne.
+ mobileView.value = 'list'
+}
+
+/** Sur desktop les deux affichages cohabitent ; sur écran étroit la bascule tranche. */
+const showList = computed(() => !isNarrow.value || mobileView.value === 'list')
+/**
+ * Carte recouverte par la liste : elle reste dimensionnée mais sort du parcours clavier
+ * et de l'arbre d'accessibilité, sinon ses marqueurs — qui sont des boutons — restent
+ * atteignables derrière la liste qui les cache.
+ */
+const mapCovered = computed(() => isNarrow.value && showList.value)
+
+/**
+ * Ouvrir une fiche depuis la liste, sur écran étroit, suppose de passer sur la carte :
+ * c'est là qu'elle s'ancre, et un appui sans effet visible se lit comme une panne.
+ * Et toujours sélectionner, jamais désélectionner — le second appui d'une bascule n'a
+ * pas de sens quand on n'a pas vu le résultat du premier.
+ */
+function onSelectDestination(label: string) {
+ if (isNarrow.value && mobileView.value === 'list') {
+ selectedDestination.value = label
+ mobileView.value = 'map'
+ return
+ }
+ selectedDestination.value = selectedDestination.value === label ? null : label
}
/** Résumé de la recherche en cours, affiché à la place du formulaire replié. */
@@ -132,19 +178,33 @@ useHead({
-
+
-
+ la carte et la liste, ses voisines immédiates sur mobile. -->
+
-
-
+
+
-
-
From e44d7fa90f3eed080c59cfa7c11c752bd02e3887 Mon Sep 17 00:00:00 2001
From: Clement <159007607+csanchez-jetdev@users.noreply.github.com>
Date: Sat, 1 Aug 2026 18:50:27 +0200
Subject: [PATCH 6/6] feat: recherche en barre horizontale sur desktop
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La recherche traverse le haut de l'écran au lieu d'occuper le tiers supérieur de
la colonne de gauche. À 1280×800, elle passe de 309 px de haut à 108, et la liste
de 4,9 à 8,1 destinations visibles.
Les rangées de dates deviennent `display: contents` sur la barre : leurs cellules
s'alignent dans le cadre sans dupliquer le balisage. Les deux textes d'aide
passent sur une seule ligne, aucun n'est supprimé — le premier est le seul endroit
où le mode de recherche est énoncé.
`flex-wrap` plutôt qu'un palier de largeur supplémentaire : la barre se plie en
deux rangées sous 1 000 px.
---
app/components/SearchBar.vue | 116 +++++++++++++++++++++++------------
app/pages/app.vue | 14 ++---
2 files changed, 84 insertions(+), 46 deletions(-)
diff --git a/app/components/SearchBar.vue b/app/components/SearchBar.vue
index 0d810e9..1c62bc0 100644
--- a/app/components/SearchBar.vue
+++ b/app/components/SearchBar.vue
@@ -181,32 +181,53 @@ function submit() {
-