anthodev/lapin-futepublic Fork
⑂ 2799b0f
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(companion): modernize settings and introduce app branding (#19)Unverified

Cédric Anthony committed 2026-09-12T18:09:01+02:00 Browse files
2799b0f parent: a46bf24
modified README.md +73 -70
@@ -1,118 +1,121 @@
1+<p align="center">
2+ <img src="packages/config-page/assets/lapin-fute-1024.png" alt="Lapin Futé icon" width="96">
3+</p>
4+
15 <h1 align="center">Lapin Futé</h1>
26
37 <p align="center">
4- <strong>Île-de-France departures on Pebble, without background traffic.</strong>
8+ <strong>Next departures and line traffic at a glance, on your Pebble.</strong>
59 <br>
6- One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary.
10+ Keep six favorite stops across Île-de-France and check them without reaching for your phone.
711 </p>
812
913 <p align="center">
10- <a href="#about">About</a>
11- ·
12- <a href="#architecture">Architecture</a>
13- ·
14- <a href="#build-from-source">Build</a>
15- ·
16- <a href="#quality-gates">Quality gates</a>
14+ <a href="#features">Features</a>
15+ · <a href="#compatibility">Compatibility</a>
16+ · <a href="#getting-started">Getting started</a>
17+ · <a href="#watch-controls">Watch controls</a>
18+ · <a href="#privacy">Privacy</a>
19+ · <a href="#build-from-source">Build from source</a>
1720 </p>
1821
1922 <p align="center">
2023 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
21- <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript">
22- <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">
24+ <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus">
25+ <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
2326 <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">
2427 </p>
2528
29+> [!IMPORTANT]
30+> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités.
31+
2632 ## About
2733
28-Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback.
34+Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
2935
30-```text
31-Departure and traffic views
32- │ symbolic AppMessage aliases
33- ▼
34-PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM
35- │
36- └── phone-local favorites (six) + personal key
37-```
36+The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops.
37+
38+## Features
3839
39-**The watch never receives the personal PRIM key and never starts network or background requests.**
40+- **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
41+- **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes.
42+- **Traffic view.** Select again on a departure board to page through the current service messages for that line.
43+- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling.
44+- **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data.
45+- **English and French.** The watch interface and the settings page are both bilingual.
46+- **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
4047
41-## Highlights
48+## Compatibility
4249
43-- **One watch runtime.** Alloy/XS owns the receiver state machine, committed configuration storage, and the Piu interface that renders prepared favorite, departure, and traffic records. The embedded sources `main`, `runtime`, `packed`, `storage`, and `ui` bundle with the generated copy into the single module the manifest declares. The only C file is the standard `mdbl.c` VM bootstrap.
44-- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` and `packages/contracts/src/index.ts` map exactly 19 aliases to the frozen IDs `0, 1, 2, 3, 10, 11, 12, 24, 35..45`, and the retired v1 IDs are never reused. Display dictionaries carry `DISPLAY_WIRE_VERSION` 2 while domain data stays at schema version 1; the SDK transport key 15025 remains outside the D2 key table.
45-- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched warm sequence cannot replace the last complete state. FULL is the deliberate exception: once the watch confirms the complete inventory and crosses the guarded durable seam, its previous configuration is already cleared, so a failed FULL leaves the watch unconfigured instead of restoring old metadata.
46-- **Event-only requests.** The watch never polls the network or schedules periodic requests. The phone fetches only when the app opens, the Refresh all row is used, a detail-view favorite change settles for 500 ms, or long Select explicitly refreshes the active favorite, each stale request gated by the 60-second cache. Trigger 5 is strictly cache-only: its overview and detail answers come from the phone cache with no network request and no refreshing echo, and a missing entry reports no cached data instead of fetching. A changed-favorite DIFF that passes the complete hash inventory with a nonzero need mask clears the watch's results and shows the loading state before any body transfers; after the commit the watch re-acquires its results from the cache only. The FULL mode is the explicit destructive resync.
47-- **Phone-prepared layout.** The phone runtime measures, wraps, clips, and packs each appearance and traffic record against pinned firmware font metrics, and the watch verifies the appearance hash before admitting it. The fixed bilingual copy corpus is generated at build time, and the watch assembles countdown and clock values locally, so it renders prepared text without any text fitting. Two display profiles exist, emery at 200×228 rectangular and gabbro at 260×260 round, chosen once at startup.
48-- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, direct PRIM requests, configuration storage, the active-watch language, and the whole display seam: font metrics, bilingual copy corpus, record preparation, and the UTF-8 forward and reverse hash. The exact personal-key boundary is unchanged.
50+| Requirement | Details |
51+|---|---|
52+| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental |
53+| Firmware | 4.32 or newer |
54+| Phone | Pebble mobile app running the companion; internet access needed for fresh data |
55+| Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 |
4956
50-## Architecture
57+## Getting started
5158
52-| Component | Owns | Must not |
53-|---|---|---|
54-| Static catalog | Offline-generated IDFM import, validated SQLite candidate, published JSON (manifest, search buckets, services, place pages) beside the config page | contain credentials or proxy live PRIM traffic |
55-| Configuration page | Statically hosted favorite editing with the six-favorite cap, masked key replacement/removal, static catalog place/service lookup | receive the stored key when reopening |
56-| PebbleKit JS | One atomic phone-local record, direct PRIM HTTPS requests, cache, watch synchronization, display preparation and packing, active-watch language | expose the key over AppMessage |
57-| Alloy watchapp | Prepared-record rendering with local navigation, committed configuration storage, loading and error states, data requests to the phone | PRIM networking or credentials; WakeUp, Worker, timeline, touch polling, text fitting, or secret storage |
59+There is no published package yet, so first use means one build and one static host:
5860
59-The embedded sources are `main`, `runtime`, `packed`, `storage`, and `ui` under `packages/watch/src/embeddedjs/`, plus the generated copy module `packages/watch/src/generated/display-copy.js`; Bun bundles them into the one module the manifest declares.
61+1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`.
62+2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page.
63+3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator.
64+4. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65+5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
66+6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
6067
61-## Targets and requirements
68+## Watch controls
6269
63-| Layer | Pinned requirement |
70+| Screen | Buttons |
6471 |---|---|
65-| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator |
66-| Firmware | 4.32 or newer |
67-| Pebble SDK | 4.33.1 |
68-| Pebble CLI | `pebble-tool` 5.0.40 |
69-| Host runtime | Node.js 24.18.0 |
70-| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media |
72+| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row |
73+| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh |
74+| Traffic | Up/Down page through messages |
75+| Anywhere | Back steps back to the previous screen |
76+
77+Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests.
7178
79+## Privacy
80+
81+Your PRIM token stays on your phone. It is stored locally and sent only as the request header of the companion's direct HTTPS calls to PRIM, which is operated by Île-de-France Mobilités. It never reaches the watch, your favorites, cached data, URLs, or logs. Browsing the settings page and its station catalog sends no credentials. The app has no telemetry, no analytics, and no background or periodic network activity.
7282
7383 ## Build from source
7484
75-Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain.
85+Install the pinned prerequisites from the table above; no repository script installs a toolchain for you.
7686
7787 ```sh
7888 npm test
79-npm run measure:radio
8089 npm run build
8190 ```
8291
83-`npm run build` runs the host suite, which generates the display copy first, then measures the modeled D2 radio exchanges, bundles the watch sources with Bun into one generated module, generates the PebbleKit JS tree, builds the single Alloy package, and verifies the generated alias-to-ID map. The watch build needs no catalog and no network. The one optional build input is the credential-free HTTPS URL of the statically hosted configuration page:
92+`npm run build` runs the test suite, generates the bilingual display copy, bundles the watchapp for emery and gabbro, and verifies the result. The watch build needs no catalog and no network. Its one optional input is the settings-page URL:
8493
8594 ```sh
86-LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \
87-npm run build
95+LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build
8896 ```
8997
90-Personal PRIM keys are never build inputs.
91-
92-The catalog toolchain (`packages/catalog`) is offline except for its operator-driven refresh. With `IDFM_DATASET_TOKEN` in `.env`, `node --env-file=.env scripts/refresh-catalog.mjs` downloads the IDFM sources, builds and validates the SQLite catalog, and publishes the static JSON catalog to `var/catalog/static`. `npm run build:config-site` then assembles the deployable site in `var/config-site`: the config page with the published catalog under `catalog/` beside `index.html`. It fails explicitly when no real catalog was published; there is no fixture fallback. `npm run catalog:probe` performs the operator-run live PRIM probe using `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed, exported, or copied into build output.
93-
94-## Privacy
95-
96-The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, then the exact PRIM `apikey` header on the companion's direct HTTPS request. Static catalog and configuration-page requests carry no credential. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.
98+The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`:
9799
98-Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.
100+```sh
101+node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog
102+npm run build:config-site # assemble var/config-site; fails without a published catalog
103+```
99104
100-## Quality gates
105+`node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output.
101106
102-| Gate | Budget |
103-|---|---|
104-| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 |
105-| Phone-to-watch dictionary | At most 768 encoded bytes |
106-| Watch-to-phone dictionary | At most 192 encoded bytes |
107-| Watch unsent queue | Four messages, at most once, one write per `onWritable` |
108-| Full six-favorite configuration | 15 dictionaries, modeled host bound |
109-| Initial D2 startup | 18 D2 dictionary attempts, modeled host bound, excluding the SDK transport; one conditional SDK announcement/echo pair adds 2 |
110-| SDK transport key | 15025 belongs to the SDK transport, never to a D2 message |
107+## Icon
111108
112-Validation status: host suites, the modeled radio gate, and release builds for both targets pass. The current production PBW has run on the Emery and Gabbro emulators against recorded fixtures with zero live PRIM calls: startup, six-favorite synchronization, detail and traffic views, unchanged and reordered DIFFs, a changed-favorite DIFF with its loading state and cache-only recovery, and FULL resync with manual recovery all executed.
109+The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`:
113110
114-Retained reopen and ten minutes of inactivity pass on both emulators; the idle windows emitted no AppMessages or additional fixture requests. With the fixed VM split, the bounded six-favorite corpus left at least 10% of the chunk arena free at measured natural compacted-live samples on both targets. These samples do not certify every transient or lifetime memory peak. Physical-device validation and the 24-hour battery gate remain open. This is not a release.
111+| File | Format | Background |
112+|---|---|---|
113+| [`lapin-fute.svg`](packages/config-page/assets/lapin-fute.svg) | Vector source | Transparent |
114+| [`lapin-fute-1024.png`](packages/config-page/assets/lapin-fute-1024.png) | PNG, 1024 × 1024 | Transparent |
115+| [`lapin-fute-1024.jpg`](packages/config-page/assets/lapin-fute-1024.jpg) | JPEG, 1024 × 1024 | White |
115116
116117 ## Contributing
117118
118-Keep the frozen contracts, symbolic runtime payloads, event-only policy, and secret boundary intact: domain data stays at schema version 1, display dictionaries at wire version 2, and `packages/watch/package.json` pins the alias-to-ID map next to the canonical contract in `packages/contracts/src/index.ts`. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests.
119+Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue.
120+
121+Code changes should keep the frozen message contracts and the phone-only credential boundary intact.
@@ -1,118 +1,121 @@
1+<p align="center">
2+ <img src="packages/config-page/assets/lapin-fute-1024.png" alt="Lapin Futé icon" width="96">
3+</p>
4+
1 <h1 align="center">Lapin Futé</h1>5 <h1 align="center">Lapin Futé</h1>
2 6
3 <p align="center">7 <p align="center">
4- <strong>Île-de-France departures on Pebble, without background traffic.</strong>8+ <strong>Next departures and line traffic at a glance, on your Pebble.</strong>
5 <br>9 <br>
6- One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary.10+ Keep six favorite stops across Île-de-France and check them without reaching for your phone.
7 </p>11 </p>
8 12
9 <p align="center">13 <p align="center">
10- <a href="#about">About</a>14+ <a href="#features">Features</a>
11- ·15+ · <a href="#compatibility">Compatibility</a>
12- <a href="#architecture">Architecture</a>16+ · <a href="#getting-started">Getting started</a>
13- ·17+ · <a href="#watch-controls">Watch controls</a>
14- <a href="#build-from-source">Build</a>18+ · <a href="#privacy">Privacy</a>
15- ·19+ · <a href="#build-from-source">Build from source</a>
16- <a href="#quality-gates">Quality gates</a>
17 </p>20 </p>
18 21
19 <p align="center">22 <p align="center">
20 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">23 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
21- <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript">24+ <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus">
22- <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">25+ <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
23 <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">26 <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">
24 </p>27 </p>
25 28
29+> [!IMPORTANT]
30+> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités.
31+
26 ## About32 ## About
27 33
28-Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback.34+Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
29 35
30-```text36+The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops.
31-Departure and traffic views37+
32- │ symbolic AppMessage aliases38+## Features
33- ▼
34-PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM
35- │
36- └── phone-local favorites (six) + personal key
37-```
38 39
39-**The watch never receives the personal PRIM key and never starts network or background requests.**40+- **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
41+- **Departures at a glance.** The favorites list already shows the next departures for each stop; Select opens a full board for one favorite. Countdowns keep ticking on the watch between refreshes.
42+- **Traffic view.** Select again on a departure board to page through the current service messages for that line.
43+- **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request departures. Recent results are reused for 60 seconds; there is no periodic network polling.
44+- **Clear data states.** The app distinguishes real-time departures, mixed real-time and scheduled results, updates in progress, and missing cached data.
45+- **English and French.** The watch interface and the settings page are both bilingual.
46+- **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach.
40 47
41-## Highlights48+## Compatibility
42 49
43-- **One watch runtime.** Alloy/XS owns the receiver state machine, committed configuration storage, and the Piu interface that renders prepared favorite, departure, and traffic records. The embedded sources `main`, `runtime`, `packed`, `storage`, and `ui` bundle with the generated copy into the single module the manifest declares. The only C file is the standard `mdbl.c` VM bootstrap.50+| Requirement | Details |
44-- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` and `packages/contracts/src/index.ts` map exactly 19 aliases to the frozen IDs `0, 1, 2, 3, 10, 11, 12, 24, 35..45`, and the retired v1 IDs are never reused. Display dictionaries carry `DISPLAY_WIRE_VERSION` 2 while domain data stays at schema version 1; the SDK transport key 15025 remains outside the D2 key table.51+|---|---|
45-- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched warm sequence cannot replace the last complete state. FULL is the deliberate exception: once the watch confirms the complete inventory and crosses the guarded durable seam, its previous configuration is already cleared, so a failed FULL leaves the watch unconfigured instead of restoring old metadata.52+| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental |
46-- **Event-only requests.** The watch never polls the network or schedules periodic requests. The phone fetches only when the app opens, the Refresh all row is used, a detail-view favorite change settles for 500 ms, or long Select explicitly refreshes the active favorite, each stale request gated by the 60-second cache. Trigger 5 is strictly cache-only: its overview and detail answers come from the phone cache with no network request and no refreshing echo, and a missing entry reports no cached data instead of fetching. A changed-favorite DIFF that passes the complete hash inventory with a nonzero need mask clears the watch's results and shows the loading state before any body transfers; after the commit the watch re-acquires its results from the cache only. The FULL mode is the explicit destructive resync.53+| Firmware | 4.32 or newer |
47-- **Phone-prepared layout.** The phone runtime measures, wraps, clips, and packs each appearance and traffic record against pinned firmware font metrics, and the watch verifies the appearance hash before admitting it. The fixed bilingual copy corpus is generated at build time, and the watch assembles countdown and clock values locally, so it renders prepared text without any text fitting. Two display profiles exist, emery at 200×228 rectangular and gabbro at 260×260 round, chosen once at startup.54+| Phone | Pebble mobile app running the companion; internet access needed for fresh data |
48-- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, direct PRIM requests, configuration storage, the active-watch language, and the whole display seam: font metrics, bilingual copy corpus, record preparation, and the UTF-8 forward and reverse hash. The exact personal-key boundary is unchanged.55+| Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 |
49 56
50-## Architecture57+## Getting started
51 58
52-| Component | Owns | Must not |59+There is no published package yet, so first use means one build and one static host:
53-|---|---|---|
54-| Static catalog | Offline-generated IDFM import, validated SQLite candidate, published JSON (manifest, search buckets, services, place pages) beside the config page | contain credentials or proxy live PRIM traffic |
55-| Configuration page | Statically hosted favorite editing with the six-favorite cap, masked key replacement/removal, static catalog place/service lookup | receive the stored key when reopening |
56-| PebbleKit JS | One atomic phone-local record, direct PRIM HTTPS requests, cache, watch synchronization, display preparation and packing, active-watch language | expose the key over AppMessage |
57-| Alloy watchapp | Prepared-record rendering with local navigation, committed configuration storage, loading and error states, data requests to the phone | PRIM networking or credentials; WakeUp, Worker, timeline, touch polling, text fitting, or secret storage |
58 60
59-The embedded sources are `main`, `runtime`, `packed`, `storage`, and `ui` under `packages/watch/src/embeddedjs/`, plus the generated copy module `packages/watch/src/generated/display-copy.js`; Bun bundles them into the one module the manifest declares.61+1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`.
62+2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page.
63+3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator.
64+4. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65+5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
66+6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
60 67
61-## Targets and requirements68+## Watch controls
62 69
63-| Layer | Pinned requirement |70+| Screen | Buttons |
64 |---|---|71 |---|---|
65-| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator |72+| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row |
66-| Firmware | 4.32 or newer |73+| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh |
67-| Pebble SDK | 4.33.1 |74+| Traffic | Up/Down page through messages |
68-| Pebble CLI | `pebble-tool` 5.0.40 |75+| Anywhere | Back steps back to the previous screen |
69-| Host runtime | Node.js 24.18.0 |76+
70-| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media |77+Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests.
71 78
79+## Privacy
80+
81+Your PRIM token stays on your phone. It is stored locally and sent only as the request header of the companion's direct HTTPS calls to PRIM, which is operated by Île-de-France Mobilités. It never reaches the watch, your favorites, cached data, URLs, or logs. Browsing the settings page and its station catalog sends no credentials. The app has no telemetry, no analytics, and no background or periodic network activity.
72 82
73 ## Build from source83 ## Build from source
74 84
75-Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain.85+Install the pinned prerequisites from the table above; no repository script installs a toolchain for you.
76 86
77 ```sh87 ```sh
78 npm test88 npm test
79-npm run measure:radio
80 npm run build89 npm run build
81 ```90 ```
82 91
83-`npm run build` runs the host suite, which generates the display copy first, then measures the modeled D2 radio exchanges, bundles the watch sources with Bun into one generated module, generates the PebbleKit JS tree, builds the single Alloy package, and verifies the generated alias-to-ID map. The watch build needs no catalog and no network. The one optional build input is the credential-free HTTPS URL of the statically hosted configuration page:92+`npm run build` runs the test suite, generates the bilingual display copy, bundles the watchapp for emery and gabbro, and verifies the result. The watch build needs no catalog and no network. Its one optional input is the settings-page URL:
84 93
85 ```sh94 ```sh
86-LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \95+LAPIN_FUTE_CONFIG_URL=https://example.org/lapin-fute/ npm run build
87-npm run build
88 ```96 ```
89 97
90-Personal PRIM keys are never build inputs.98+The station catalog is generated ahead of time from open IDFM datasets. With `IDFM_DATASET_TOKEN` in `.env`:
91-
92-The catalog toolchain (`packages/catalog`) is offline except for its operator-driven refresh. With `IDFM_DATASET_TOKEN` in `.env`, `node --env-file=.env scripts/refresh-catalog.mjs` downloads the IDFM sources, builds and validates the SQLite catalog, and publishes the static JSON catalog to `var/catalog/static`. `npm run build:config-site` then assembles the deployable site in `var/config-site`: the config page with the published catalog under `catalog/` beside `index.html`. It fails explicitly when no real catalog was published; there is no fixture fallback. `npm run catalog:probe` performs the operator-run live PRIM probe using `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed, exported, or copied into build output.
93-
94-## Privacy
95-
96-The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, then the exact PRIM `apikey` header on the companion's direct HTTPS request. Static catalog and configuration-page requests carry no credential. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.
97 99
98-Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.100+```sh
101+node --env-file=.env scripts/refresh-catalog.mjs # download, build, and publish the catalog
102+npm run build:config-site # assemble var/config-site; fails without a published catalog
103+```
99 104
100-## Quality gates105+`node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output.
101 106
102-| Gate | Budget |107+## Icon
103-|---|---|
104-| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 |
105-| Phone-to-watch dictionary | At most 768 encoded bytes |
106-| Watch-to-phone dictionary | At most 192 encoded bytes |
107-| Watch unsent queue | Four messages, at most once, one write per `onWritable` |
108-| Full six-favorite configuration | 15 dictionaries, modeled host bound |
109-| Initial D2 startup | 18 D2 dictionary attempts, modeled host bound, excluding the SDK transport; one conditional SDK announcement/echo pair adds 2 |
110-| SDK transport key | 15025 belongs to the SDK transport, never to a D2 message |
111 108
112-Validation status: host suites, the modeled radio gate, and release builds for both targets pass. The current production PBW has run on the Emery and Gabbro emulators against recorded fixtures with zero live PRIM calls: startup, six-favorite synchronization, detail and traffic views, unchanged and reordered DIFFs, a changed-favorite DIFF with its loading state and cache-only recovery, and FULL resync with manual recovery all executed.109+The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`:
113 110
114-Retained reopen and ten minutes of inactivity pass on both emulators; the idle windows emitted no AppMessages or additional fixture requests. With the fixed VM split, the bounded six-favorite corpus left at least 10% of the chunk arena free at measured natural compacted-live samples on both targets. These samples do not certify every transient or lifetime memory peak. Physical-device validation and the 24-hour battery gate remain open. This is not a release.111+| File | Format | Background |
112+|---|---|---|
113+| [`lapin-fute.svg`](packages/config-page/assets/lapin-fute.svg) | Vector source | Transparent |
114+| [`lapin-fute-1024.png`](packages/config-page/assets/lapin-fute-1024.png) | PNG, 1024 × 1024 | Transparent |
115+| [`lapin-fute-1024.jpg`](packages/config-page/assets/lapin-fute-1024.jpg) | JPEG, 1024 × 1024 | White |
115 116
116 ## Contributing117 ## Contributing
117 118
118-Keep the frozen contracts, symbolic runtime payloads, event-only policy, and secret boundary intact: domain data stays at schema version 1, display dictionaries at wire version 2, and `packages/watch/package.json` pins the alias-to-ID map next to the canonical contract in `packages/contracts/src/index.ts`. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests.119+Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue.
120+
121+Code changes should keep the frozen message contracts and the phone-only credential boundary intact.
modified packages/catalog/src/catalog.ts +50 -12
@@ -2,6 +2,8 @@ import { DatabaseSync, type StatementSync } from "node:sqlite";
22 import {
33 LIMITS,
44 TRANSPORT_MODE,
5+ isPlaceLine,
6+ type PlaceLine,
57 type PlaceSearchItem,
68 type ServiceOption,
79 type TransportMode,
@@ -30,6 +32,7 @@ interface PlaceRow {
3032 readonly stopLabel: string;
3133 readonly localityLabel: string | null;
3234 readonly mode: TransportMode;
35+ readonly lines: string;
3336 }
3437
3538 interface ServiceRow {
@@ -446,11 +449,40 @@ function normalizedMatchQuery(query: string): string {
446449 .join(" AND ");
447450 }
448451
452+// Keep native identity until display ordering and duplicate checks are complete.
453+export const PLACE_LINES_SQL = `json_group_array(DISTINCT json_object(
454+ 'lineRef', services.line_ref,
455+ 'lineLabel', services.line_label,
456+ 'lineColor', services.line_color,
457+ 'lineTextColor', services.line_text_color
458+))`;
459+const lineLabelOrder = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
460+
461+export function placeLinesFromJson(serialized: string): PlaceLine[] {
462+ const entries = JSON.parse(serialized) as (PlaceLine & { readonly lineRef: string })[];
463+ entries.sort((left, right) => lineLabelOrder.compare(left.lineLabel, right.lineLabel)
464+ || (left.lineRef < right.lineRef ? -1 : left.lineRef > right.lineRef ? 1 : 0));
465+ const identities = new Set<string>();
466+ return entries.map((entry) => {
467+ // DISTINCT removed destination copies. A remaining duplicate ref is conflicting metadata.
468+ if (identities.has(entry.lineRef)) invalidCandidate("inconsistent line metadata");
469+ identities.add(entry.lineRef);
470+ const line = {
471+ lineLabel: entry.lineLabel,
472+ lineColor: entry.lineColor,
473+ lineTextColor: entry.lineTextColor,
474+ };
475+ if (!isPlaceLine(line)) invalidCandidate("line metadata");
476+ return line;
477+ });
478+}
479+
449480 function placeItem(entry: PlaceRow): PlaceSearchItem {
450481 const item: PlaceSearchItem = {
451482 placeId: entry.placeId,
452483 stopLabel: entry.stopLabel,
453484 mode: entry.mode,
485+ lines: placeLinesFromJson(entry.lines),
454486 };
455487 if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel;
456488 return item;
@@ -467,19 +499,25 @@ export class SqliteCatalogReader implements CatalogReader {
467499 private constructor(database: DatabaseSync) {
468500 this.#database = database;
469501 this.#searchPlacesStatement = database.prepare(`
502+ WITH matches AS MATERIALIZED (
503+ SELECT places.place_id, places.stop_label, places.locality_label, places.mode,
504+ bm25(place_search) AS search_rank
505+ FROM place_search
506+ JOIN places ON places.place_id = place_search.place_id
507+ WHERE place_search MATCH ?
508+ ORDER BY search_rank, places.stop_label COLLATE NOCASE, places.place_id
509+ LIMIT ?
510+ )
470511 SELECT
471- places.place_id AS placeId,
472- places.stop_label AS stopLabel,
473- places.locality_label AS localityLabel,
474- places.mode AS mode
475- FROM place_search
476- JOIN places ON places.place_id = place_search.place_id
477- WHERE place_search MATCH ?
478- ORDER BY
479- bm25(place_search),
480- places.stop_label COLLATE NOCASE,
481- places.place_id
482- LIMIT ?
512+ matches.place_id AS placeId,
513+ matches.stop_label AS stopLabel,
514+ matches.locality_label AS localityLabel,
515+ matches.mode AS mode,
516+ ${PLACE_LINES_SQL} AS lines
517+ FROM matches
518+ JOIN services ON services.place_id = matches.place_id
519+ GROUP BY matches.place_id
520+ ORDER BY matches.search_rank, matches.stop_label COLLATE NOCASE, matches.place_id
483521 `);
484522 this.#placeExistsStatement = database.prepare(`
485523 SELECT 1
@@ -2,6 +2,8 @@ import { DatabaseSync, type StatementSync } from "node:sqlite";
2 import {2 import {
3 LIMITS,3 LIMITS,
4 TRANSPORT_MODE,4 TRANSPORT_MODE,
5+ isPlaceLine,
6+ type PlaceLine,
5 type PlaceSearchItem,7 type PlaceSearchItem,
6 type ServiceOption,8 type ServiceOption,
7 type TransportMode,9 type TransportMode,
@@ -30,6 +32,7 @@ interface PlaceRow {
30 readonly stopLabel: string;32 readonly stopLabel: string;
31 readonly localityLabel: string | null;33 readonly localityLabel: string | null;
32 readonly mode: TransportMode;34 readonly mode: TransportMode;
35+ readonly lines: string;
33 }36 }
34 37
35 interface ServiceRow {38 interface ServiceRow {
@@ -446,11 +449,40 @@ function normalizedMatchQuery(query: string): string {
446 .join(" AND ");449 .join(" AND ");
447 }450 }
448 451
452+// Keep native identity until display ordering and duplicate checks are complete.
453+export const PLACE_LINES_SQL = `json_group_array(DISTINCT json_object(
454+ 'lineRef', services.line_ref,
455+ 'lineLabel', services.line_label,
456+ 'lineColor', services.line_color,
457+ 'lineTextColor', services.line_text_color
458+))`;
459+const lineLabelOrder = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
460+
461+export function placeLinesFromJson(serialized: string): PlaceLine[] {
462+ const entries = JSON.parse(serialized) as (PlaceLine & { readonly lineRef: string })[];
463+ entries.sort((left, right) => lineLabelOrder.compare(left.lineLabel, right.lineLabel)
464+ || (left.lineRef < right.lineRef ? -1 : left.lineRef > right.lineRef ? 1 : 0));
465+ const identities = new Set<string>();
466+ return entries.map((entry) => {
467+ // DISTINCT removed destination copies. A remaining duplicate ref is conflicting metadata.
468+ if (identities.has(entry.lineRef)) invalidCandidate("inconsistent line metadata");
469+ identities.add(entry.lineRef);
470+ const line = {
471+ lineLabel: entry.lineLabel,
472+ lineColor: entry.lineColor,
473+ lineTextColor: entry.lineTextColor,
474+ };
475+ if (!isPlaceLine(line)) invalidCandidate("line metadata");
476+ return line;
477+ });
478+}
479+
449 function placeItem(entry: PlaceRow): PlaceSearchItem {480 function placeItem(entry: PlaceRow): PlaceSearchItem {
450 const item: PlaceSearchItem = {481 const item: PlaceSearchItem = {
451 placeId: entry.placeId,482 placeId: entry.placeId,
452 stopLabel: entry.stopLabel,483 stopLabel: entry.stopLabel,
453 mode: entry.mode,484 mode: entry.mode,
485+ lines: placeLinesFromJson(entry.lines),
454 };486 };
455 if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel;487 if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel;
456 return item;488 return item;
@@ -467,19 +499,25 @@ export class SqliteCatalogReader implements CatalogReader {
467 private constructor(database: DatabaseSync) {499 private constructor(database: DatabaseSync) {
468 this.#database = database;500 this.#database = database;
469 this.#searchPlacesStatement = database.prepare(`501 this.#searchPlacesStatement = database.prepare(`
502+ WITH matches AS MATERIALIZED (
503+ SELECT places.place_id, places.stop_label, places.locality_label, places.mode,
504+ bm25(place_search) AS search_rank
505+ FROM place_search
506+ JOIN places ON places.place_id = place_search.place_id
507+ WHERE place_search MATCH ?
508+ ORDER BY search_rank, places.stop_label COLLATE NOCASE, places.place_id
509+ LIMIT ?
510+ )
470 SELECT511 SELECT
471- places.place_id AS placeId,512+ matches.place_id AS placeId,
472- places.stop_label AS stopLabel,513+ matches.stop_label AS stopLabel,
473- places.locality_label AS localityLabel,514+ matches.locality_label AS localityLabel,
474- places.mode AS mode515+ matches.mode AS mode,
475- FROM place_search516+ ${PLACE_LINES_SQL} AS lines
476- JOIN places ON places.place_id = place_search.place_id517+ FROM matches
477- WHERE place_search MATCH ?518+ JOIN services ON services.place_id = matches.place_id
478- ORDER BY519+ GROUP BY matches.place_id
479- bm25(place_search),520+ ORDER BY matches.search_rank, matches.stop_label COLLATE NOCASE, matches.place_id
480- places.stop_label COLLATE NOCASE,
481- places.place_id
482- LIMIT ?
483 `);521 `);
484 this.#placeExistsStatement = database.prepare(`522 this.#placeExistsStatement = database.prepare(`
485 SELECT 1523 SELECT 1
modified packages/catalog/src/static-catalog.ts +11 -4
@@ -28,6 +28,7 @@ import {
2828 validateCatalogCandidate,
2929 type CatalogSourceAttribution,
3030 } from "./catalog-import.ts";
31+import { PLACE_LINES_SQL, placeLinesFromJson } from "./catalog.ts";
3132 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
3233
3334 const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u;
@@ -72,8 +73,11 @@ interface PageWriter {
7273 const PLACE_QUERY = `
7374 SELECT places.place_id AS placeId, places.stop_label AS stopLabel,
7475 places.locality_label AS localityLabel, places.mode AS mode,
75- place_search.search_text AS searchText
76- FROM places JOIN place_search ON place_search.place_id = places.place_id
76+ place_search.search_text AS searchText, ${PLACE_LINES_SQL} AS lines
77+ FROM places
78+ JOIN place_search ON place_search.place_id = places.place_id
79+ JOIN services ON services.place_id = places.place_id
80+ GROUP BY places.place_id
7781 ORDER BY places.place_id
7882 `;
7983 const SERVICE_QUERY = `
@@ -115,11 +119,13 @@ function publicAttribution(sources: readonly CatalogSourceAttribution[]): Catalo
115119 }
116120
117121 function searchRow(row: SqlRow): SearchRow {
122+ if (typeof row.lines !== "string") fail("invalid search row");
118123 const place = {
119124 placeId: row.placeId,
120125 stopLabel: row.stopLabel,
121126 ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }),
122127 mode: row.mode,
128+ lines: placeLinesFromJson(row.lines),
123129 };
124130 if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
125131 || typeof row.searchText !== "string" || row.searchText === ""
@@ -209,9 +215,10 @@ export function publishStaticCatalog(options: PublishStaticCatalogOptions): Stat
209215 }
210216 // Hash the same pinned SQLite snapshot that will be exported, in bounded rows.
211217 const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution }));
212- for (const query of [PLACE_QUERY, SERVICE_QUERY]) {
213- for (const row of database.prepare(query).iterate()) digest.update(JSON.stringify(row)).update("\n");
218+ for (const row of database.prepare(PLACE_QUERY).iterate() as Iterable<SqlRow>) {
219+ digest.update(JSON.stringify(searchRow(row))).update("\n");
214220 }
221+ for (const row of database.prepare(SERVICE_QUERY).iterate()) digest.update(JSON.stringify(row)).update("\n");
215222 const revision = digest.digest("hex");
216223 const manifest: StaticCatalogManifest = {
217224 schemaVersion: SCHEMA_VERSION,
@@ -28,6 +28,7 @@ import {
28 validateCatalogCandidate,28 validateCatalogCandidate,
29 type CatalogSourceAttribution,29 type CatalogSourceAttribution,
30 } from "./catalog-import.ts";30 } from "./catalog-import.ts";
31+import { PLACE_LINES_SQL, placeLinesFromJson } from "./catalog.ts";
31 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";32 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
32 33
33 const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u;34 const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u;
@@ -72,8 +73,11 @@ interface PageWriter {
72 const PLACE_QUERY = `73 const PLACE_QUERY = `
73 SELECT places.place_id AS placeId, places.stop_label AS stopLabel,74 SELECT places.place_id AS placeId, places.stop_label AS stopLabel,
74 places.locality_label AS localityLabel, places.mode AS mode,75 places.locality_label AS localityLabel, places.mode AS mode,
75- place_search.search_text AS searchText76+ place_search.search_text AS searchText, ${PLACE_LINES_SQL} AS lines
76- FROM places JOIN place_search ON place_search.place_id = places.place_id77+ FROM places
78+ JOIN place_search ON place_search.place_id = places.place_id
79+ JOIN services ON services.place_id = places.place_id
80+ GROUP BY places.place_id
77 ORDER BY places.place_id81 ORDER BY places.place_id
78 `;82 `;
79 const SERVICE_QUERY = `83 const SERVICE_QUERY = `
@@ -115,11 +119,13 @@ function publicAttribution(sources: readonly CatalogSourceAttribution[]): Catalo
115 }119 }
116 120
117 function searchRow(row: SqlRow): SearchRow {121 function searchRow(row: SqlRow): SearchRow {
122+ if (typeof row.lines !== "string") fail("invalid search row");
118 const place = {123 const place = {
119 placeId: row.placeId,124 placeId: row.placeId,
120 stopLabel: row.stopLabel,125 stopLabel: row.stopLabel,
121 ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }),126 ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }),
122 mode: row.mode,127 mode: row.mode,
128+ lines: placeLinesFromJson(row.lines),
123 };129 };
124 if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)130 if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
125 || typeof row.searchText !== "string" || row.searchText === ""131 || typeof row.searchText !== "string" || row.searchText === ""
@@ -209,9 +215,10 @@ export function publishStaticCatalog(options: PublishStaticCatalogOptions): Stat
209 }215 }
210 // Hash the same pinned SQLite snapshot that will be exported, in bounded rows.216 // Hash the same pinned SQLite snapshot that will be exported, in bounded rows.
211 const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution }));217 const digest = createHash("sha256").update(EXPORT_FORMAT).update(JSON.stringify({ metadata, attribution }));
212- for (const query of [PLACE_QUERY, SERVICE_QUERY]) {218+ for (const row of database.prepare(PLACE_QUERY).iterate() as Iterable<SqlRow>) {
213- for (const row of database.prepare(query).iterate()) digest.update(JSON.stringify(row)).update("\n");219+ digest.update(JSON.stringify(searchRow(row))).update("\n");
214 }220 }
221+ for (const row of database.prepare(SERVICE_QUERY).iterate()) digest.update(JSON.stringify(row)).update("\n");
215 const revision = digest.digest("hex");222 const revision = digest.digest("hex");
216 const manifest: StaticCatalogManifest = {223 const manifest: StaticCatalogManifest = {
217 schemaVersion: SCHEMA_VERSION,224 schemaVersion: SCHEMA_VERSION,
modified packages/catalog/test/catalog.test.ts +75 -4
@@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { DatabaseSync } from "node:sqlite";
8-import { LIMITS, type TransportMode } from "../../contracts/src/index.ts";
8+import { LIMITS, isPlaceSearchItem, type TransportMode } from "../../contracts/src/index.ts";
99 import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts";
1010
1111 interface PlaceSeed {
@@ -315,8 +315,8 @@ test("search is accent tolerant, punctuation safe, and bounded by Unicode code p
315315 assert.throws(() => reader.searchPlaces("a"), RangeError);
316316 assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError);
317317 assert.throws(() => reader.searchPlaces("--"), RangeError);
318- assert.doesNotThrow(() => reader.searchPlaces("😀a"));
319- assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100)));
318+ assert.deepEqual(reader.searchPlaces("😀a"), []);
319+ assert.deepEqual(reader.searchPlaces("z".repeat(100)), []);
320320 });
321321
322322 test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => {
@@ -373,8 +373,76 @@ test("search preserves homonyms and applies deterministic ranking before the 20-
373373 );
374374 });
375375
376+test("search deduplicates destinations by native line and keeps equal labels and homonymous modes", () => {
377+ const places: PlaceSeed[] = (["BUS", "METRO"] as const).map((mode) => ({
378+ key: `university-${mode}`,
379+ mode,
380+ sourceCode: "zda-university",
381+ stopLabel: "Saint-Denis Université",
382+ localityLabel: "Saint-Denis",
383+ }));
384+ const busLines = [
385+ { lineRef: "native-10", lineLabel: "10", lineColor: "#333333" },
386+ { lineRef: "native-2-b", lineLabel: "2", lineColor: "#222222" },
387+ { lineRef: "native-2-c", lineLabel: "2", lineColor: "#111111" },
388+ { lineRef: "native-2-a", lineLabel: "2", lineColor: "#111111" },
389+ ];
390+ const services: ServiceSeed[] = busLines.flatMap((line, index) => [0, 1].map((direction) => ({
391+ ...line,
392+ key: `university-bus-${index}-${direction}`,
393+ placeKey: "university-BUS",
394+ mode: "BUS" as const,
395+ stopLabel: "Saint-Denis Université",
396+ lineTextColor: "#ffffff",
397+ destinationLabel: `Terminus ${direction}`,
398+ monitoringRef: `university-bus-${direction}`,
399+ directionId: String(direction),
400+ destinationRef: `university-terminal-${direction}`,
401+ })));
402+ services.push({
403+ key: "university-metro",
404+ placeKey: "university-METRO",
405+ mode: "METRO",
406+ stopLabel: "Saint-Denis Université",
407+ lineLabel: "13",
408+ lineColor: "#82c8e6",
409+ lineTextColor: "#000000",
410+ destinationLabel: "Châtillon Montrouge",
411+ monitoringRef: "university-metro",
412+ lineRef: "native-metro-13",
413+ directionId: "0",
414+ destinationRef: "metro-terminal",
415+ });
416+ const catalog = buildCatalog({ places, services });
417+ const matches = SqliteCatalogReader.open(catalog.path).searchPlaces("universite");
418+ assert.equal(matches.length, 2);
419+ assert.equal(matches.every(isPlaceSearchItem), true);
420+ assert.deepEqual(matches.find((place) => place.mode === "BUS")?.lines, [
421+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
422+ { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" },
423+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
424+ { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" },
425+ ]);
426+ assert.deepEqual(matches.find((place) => place.mode === "METRO")?.lines, [
427+ { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" },
428+ ]);
429+});
430+
431+test("search rejects conflicting display metadata for one native line", () => {
432+ const catalog = buildCatalog({
433+ services: [{
434+ ...BASE_SERVICES[0]!,
435+ key: "bus-other-destination",
436+ destinationLabel: "Other terminus",
437+ destinationRef: "terminal-bus-other",
438+ lineColor: "#654321",
439+ }],
440+ });
441+ assert.throws(() => SqliteCatalogReader.open(catalog.path).searchPlaces("republique"), /inconsistent line metadata/u);
442+});
443+
376444 test("service options return every valid direction without applying the place-search bound", () => {
377- const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({
445+ const extraServices: ServiceSeed[] = Array.from({ length: 65 }, (_, index) => ({
378446 key: `bus-option-${index}`,
379447 placeKey: "bus",
380448 mode: "BUS",
@@ -391,6 +459,9 @@ test("service options return every valid direction without applying the place-se
391459
392460 const options = reader.listServices(catalog.placeIds.bus);
393461 assert.equal(options?.length, extraServices.length + 1);
462+ const place = reader.searchPlaces("republique").find((entry) => entry.placeId === catalog.placeIds.bus);
463+ assert.equal(isPlaceSearchItem(place), true);
464+ assert.deepEqual(place?.lines.map((line) => line.lineLabel), ["96", ...extraServices.map((service) => service.lineLabel)]);
394465 });
395466
396467 test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => {
@@ -5,7 +5,7 @@ import { mkdtempSync, rmSync } from "node:fs";
5 import { tmpdir } from "node:os";5 import { tmpdir } from "node:os";
6 import { join } from "node:path";6 import { join } from "node:path";
7 import { DatabaseSync } from "node:sqlite";7 import { DatabaseSync } from "node:sqlite";
8-import { LIMITS, type TransportMode } from "../../contracts/src/index.ts";8+import { LIMITS, isPlaceSearchItem, type TransportMode } from "../../contracts/src/index.ts";
9 import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts";9 import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts";
10 10
11 interface PlaceSeed {11 interface PlaceSeed {
@@ -315,8 +315,8 @@ test("search is accent tolerant, punctuation safe, and bounded by Unicode code p
315 assert.throws(() => reader.searchPlaces("a"), RangeError);315 assert.throws(() => reader.searchPlaces("a"), RangeError);
316 assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError);316 assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError);
317 assert.throws(() => reader.searchPlaces("--"), RangeError);317 assert.throws(() => reader.searchPlaces("--"), RangeError);
318- assert.doesNotThrow(() => reader.searchPlaces("😀a"));318+ assert.deepEqual(reader.searchPlaces("😀a"), []);
319- assert.doesNotThrow(() => reader.searchPlaces("z".repeat(100)));319+ assert.deepEqual(reader.searchPlaces("z".repeat(100)), []);
320 });320 });
321 321
322 test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => {322 test("search preserves homonyms and applies deterministic ranking before the 20-row bound", () => {
@@ -373,8 +373,76 @@ test("search preserves homonyms and applies deterministic ranking before the 20-
373 );373 );
374 });374 });
375 375
376+test("search deduplicates destinations by native line and keeps equal labels and homonymous modes", () => {
377+ const places: PlaceSeed[] = (["BUS", "METRO"] as const).map((mode) => ({
378+ key: `university-${mode}`,
379+ mode,
380+ sourceCode: "zda-university",
381+ stopLabel: "Saint-Denis Université",
382+ localityLabel: "Saint-Denis",
383+ }));
384+ const busLines = [
385+ { lineRef: "native-10", lineLabel: "10", lineColor: "#333333" },
386+ { lineRef: "native-2-b", lineLabel: "2", lineColor: "#222222" },
387+ { lineRef: "native-2-c", lineLabel: "2", lineColor: "#111111" },
388+ { lineRef: "native-2-a", lineLabel: "2", lineColor: "#111111" },
389+ ];
390+ const services: ServiceSeed[] = busLines.flatMap((line, index) => [0, 1].map((direction) => ({
391+ ...line,
392+ key: `university-bus-${index}-${direction}`,
393+ placeKey: "university-BUS",
394+ mode: "BUS" as const,
395+ stopLabel: "Saint-Denis Université",
396+ lineTextColor: "#ffffff",
397+ destinationLabel: `Terminus ${direction}`,
398+ monitoringRef: `university-bus-${direction}`,
399+ directionId: String(direction),
400+ destinationRef: `university-terminal-${direction}`,
401+ })));
402+ services.push({
403+ key: "university-metro",
404+ placeKey: "university-METRO",
405+ mode: "METRO",
406+ stopLabel: "Saint-Denis Université",
407+ lineLabel: "13",
408+ lineColor: "#82c8e6",
409+ lineTextColor: "#000000",
410+ destinationLabel: "Châtillon Montrouge",
411+ monitoringRef: "university-metro",
412+ lineRef: "native-metro-13",
413+ directionId: "0",
414+ destinationRef: "metro-terminal",
415+ });
416+ const catalog = buildCatalog({ places, services });
417+ const matches = SqliteCatalogReader.open(catalog.path).searchPlaces("universite");
418+ assert.equal(matches.length, 2);
419+ assert.equal(matches.every(isPlaceSearchItem), true);
420+ assert.deepEqual(matches.find((place) => place.mode === "BUS")?.lines, [
421+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
422+ { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" },
423+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
424+ { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" },
425+ ]);
426+ assert.deepEqual(matches.find((place) => place.mode === "METRO")?.lines, [
427+ { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" },
428+ ]);
429+});
430+
431+test("search rejects conflicting display metadata for one native line", () => {
432+ const catalog = buildCatalog({
433+ services: [{
434+ ...BASE_SERVICES[0]!,
435+ key: "bus-other-destination",
436+ destinationLabel: "Other terminus",
437+ destinationRef: "terminal-bus-other",
438+ lineColor: "#654321",
439+ }],
440+ });
441+ assert.throws(() => SqliteCatalogReader.open(catalog.path).searchPlaces("republique"), /inconsistent line metadata/u);
442+});
443+
376 test("service options return every valid direction without applying the place-search bound", () => {444 test("service options return every valid direction without applying the place-search bound", () => {
377- const extraServices: ServiceSeed[] = Array.from({ length: 25 }, (_, index) => ({445+ const extraServices: ServiceSeed[] = Array.from({ length: 65 }, (_, index) => ({
378 key: `bus-option-${index}`,446 key: `bus-option-${index}`,
379 placeKey: "bus",447 placeKey: "bus",
380 mode: "BUS",448 mode: "BUS",
@@ -391,6 +459,9 @@ test("service options return every valid direction without applying the place-se
391 459
392 const options = reader.listServices(catalog.placeIds.bus);460 const options = reader.listServices(catalog.placeIds.bus);
393 assert.equal(options?.length, extraServices.length + 1);461 assert.equal(options?.length, extraServices.length + 1);
462+ const place = reader.searchPlaces("republique").find((entry) => entry.placeId === catalog.placeIds.bus);
463+ assert.equal(isPlaceSearchItem(place), true);
464+ assert.deepEqual(place?.lines.map((line) => line.lineLabel), ["96", ...extraServices.map((service) => service.lineLabel)]);
394 });465 });
395 466
396 test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => {467 test("reload preserves stable IDs, distinguishes directions, rejects invalid candidates, and removes stale services", () => {
modified packages/catalog/test/static-catalog.test.js +109 -4
@@ -4,8 +4,9 @@ import { existsSync, mkdtempSync, opendirSync, readFileSync, rmSync } from "node
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { DatabaseSync } from "node:sqlite";
7-import { LIMITS, TRANSPORT_MODE } from "../../contracts/src/index.ts";
7+import { LIMITS, TRANSPORT_MODE, isPlaceSearchItem } from "../../contracts/src/index.ts";
88 import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts";
9+import { SqliteCatalogReader } from "../src/catalog.ts";
910 import { publishStaticCatalog } from "../src/static-catalog.ts";
1011 import { createCatalogClient } from "../../config-page/src/catalog-client.js";
1112 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
@@ -58,12 +59,22 @@ test("validated catalog publication preserves all modes, stable service identity
5859 const options = await catalogFixture(t);
5960 const publication = publishStaticCatalog(options);
6061 const client = pageClient(options.outputDirectory);
62+ const reader = SqliteCatalogReader.open(options.catalogPath);
6163 const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"];
6264 const modes = new Set();
6365 for (const query of selections) {
6466 for (const place of await client.searchPlaces(query)) {
6567 modes.add(place.mode);
66- for (const service of await client.listServices(place.placeId)) {
68+ assert.equal(isPlaceSearchItem(place), true);
69+ assert.deepEqual(place, reader.searchPlaces(query).find((entry) => entry.placeId === place.placeId));
70+ const services = await client.listServices(place.placeId);
71+ const nativeLines = new Map(services.map((service) => [service.routing.lineRef, {
72+ lineLabel: service.lineLabel, lineColor: service.lineColor, lineTextColor: service.lineTextColor,
73+ }]));
74+ assert.equal(place.lines.length, nativeLines.size);
75+ for (const line of nativeLines.values()) assert.ok(place.lines.some((entry) =>
76+ entry.lineLabel === line.lineLabel && entry.lineColor === line.lineColor && entry.lineTextColor === line.lineTextColor));
77+ for (const service of services) {
6778 assert.equal(service.lineMode, place.mode);
6879 assert.deepEqual(await client.lookupService(service.serviceId), service);
6980 }
@@ -88,6 +99,83 @@ test("validated catalog publication preserves all modes, stable service identity
8899 assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before);
89100 });
90101
102+test("search publication keeps every native line in natural order and refreshes display metadata by revision", async (t) => {
103+ const options = await catalogFixture(t);
104+ const database = new DatabaseSync(options.catalogPath);
105+ const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
106+ const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
107+ const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
108+ const busLines = [
109+ { lineId: "C-UNI10", lineLabel: "10", lineColor: "#333333" },
110+ { lineId: "C-UNI2-B", lineLabel: "2", lineColor: "#222222" },
111+ { lineId: "C-UNI2-C", lineLabel: "2", lineColor: "#111111" },
112+ { lineId: "C-UNI2-A", lineLabel: "2", lineColor: "#111111" },
113+ ];
114+ try {
115+ database.exec("BEGIN");
116+ for (const [mode, lines] of [
117+ ["BUS", busLines],
118+ ["METRO", [{ lineId: "C-UNI13", lineLabel: "13", lineColor: "#82c8e6" }]],
119+ ]) {
120+ const place = createPlaceIdentity(mode, "STATIC-UNIVERSITY");
121+ insertPlace.run(place.placeId, "Saint-Denis Université", "Saint-Denis", mode, place.canonicalTuple);
122+ insertSearch.run(normalizeCatalogSearchText(`Saint-Denis Université ${mode}`), place.placeId);
123+ for (const line of lines) {
124+ for (const direction of ["0", "1"]) {
125+ const monitoringRef = `fixture:university:${mode}:${direction}`;
126+ const destinationRef = `fixture:university:terminal:${direction}`;
127+ const service = createServiceIdentity({ mode, lineId: line.lineId, monitoringRef, directionId: direction, destinationRef });
128+ insertService.run(
129+ service.serviceId, place.placeId, "Saint-Denis Université", line.lineLabel, `Terminus ${direction}`,
130+ line.lineColor, "#ffffff", monitoringRef, `IDFM:${line.lineId}`, direction, destinationRef, service.canonicalTuple,
131+ );
132+ }
133+ }
134+ }
135+ database.exec("COMMIT");
136+ } finally {
137+ database.close();
138+ }
139+
140+ const first = publishStaticCatalog(options);
141+ const requests = [];
142+ const oldClient = pageClient(options.outputDirectory, requests);
143+ const places = await oldClient.searchPlaces("universite");
144+ assert.equal(places.length, 2);
145+ assert.equal(places.every(isPlaceSearchItem), true);
146+ assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false);
147+ const expectedBus = [
148+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
149+ { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" },
150+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
151+ { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" },
152+ ];
153+ assert.deepEqual(places.find((place) => place.mode === "BUS").lines, expectedBus);
154+ assert.deepEqual(places.find((place) => place.mode === "METRO").lines, [
155+ { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#ffffff" },
156+ ]);
157+ const live = SqliteCatalogReader.open(options.catalogPath).searchPlaces("universite");
158+ for (const place of places) assert.deepEqual(place, live.find((entry) => entry.placeId === place.placeId));
159+
160+ const update = new DatabaseSync(options.catalogPath);
161+ try {
162+ update.prepare("UPDATE services SET line_label = ?, line_color = ?, line_text_color = ? WHERE line_ref = ?")
163+ .run("3", "#abcdef", "#000000", "IDFM:C-UNI2-B");
164+ } finally {
165+ update.close();
166+ }
167+ const second = publishStaticCatalog(options);
168+ assert.equal(second.manifest.sourceRevision, first.manifest.sourceRevision);
169+ assert.notEqual(second.manifest.revision, first.manifest.revision);
170+ const refreshed = await pageClient(options.outputDirectory).searchPlaces("universite");
171+ assert.deepEqual(refreshed.find((place) => place.mode === "BUS").lines, [
172+ expectedBus[0], expectedBus[2],
173+ { lineLabel: "3", lineColor: "#abcdef", lineTextColor: "#000000" },
174+ expectedBus[3],
175+ ]);
176+ assert.deepEqual((await oldClient.searchPlaces("universite")).find((place) => place.mode === "BUS").lines, expectedBus);
177+});
178+
91179 test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => {
92180 const first = await catalogFixture(t);
93181 const oldPublication = publishStaticCatalog(first);
@@ -129,9 +217,10 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
129217 insertSearch.run("busy fixture", busy.placeId);
130218 for (let index = 0; index < 12; index += 1) {
131219 const destinationRef = `fixture:busy:${index}`;
132- const service = createServiceIdentity({ mode: "BUS", lineId: "C-BUSY", monitoringRef: longMonitoring, directionId: "0", destinationRef });
220+ const lineId = `C-BUSY-${index}`;
221+ const service = createServiceIdentity({ mode: "BUS", lineId, monitoringRef: longMonitoring, directionId: "0", destinationRef });
133222 longServiceId ??= service.serviceId;
134- insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, "IDFM:C-BUSY", "0", destinationRef, service.canonicalTuple);
223+ insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, `IDFM:${lineId}`, "0", destinationRef, service.canonicalTuple);
135224 }
136225 database.exec("COMMIT");
137226 } finally {
@@ -147,6 +236,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
147236 const client = pageClient(options.outputDirectory, requests);
148237 const matches = await client.searchPlaces("café");
149238 assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`));
239+ assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false);
240+ for (const place of matches) assert.deepEqual(place.lines, [
241+ { lineLabel: "99", lineColor: "#123456", lineTextColor: "#ffffff" },
242+ ]);
243+ const busyMatches = await client.searchPlaces("busy");
244+ assert.deepEqual(busyMatches[0].lines, Array.from({ length: 12 }, (_, index) => ({
245+ lineLabel: String(index).padStart(2, "0"), lineColor: "#123456", lineTextColor: "#ffffff",
246+ })));
150247 const services = await client.listServices(busy.placeId);
151248 assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0")));
152249 assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring);
@@ -154,6 +251,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
154251 assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true);
155252 assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId));
156253 assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true);
254+ const exportedPlaces = [];
255+ for (let page = 0; page !== null;) {
256+ const body = JSON.parse(readFileSync(join(options.outputDirectory, revision, "search", "63_61", `${page}.json`), "utf8"));
257+ exportedPlaces.push(...body.places.map((place) => place.placeId));
258+ page = body.nextPage;
259+ }
260+ assert.deepEqual(exportedPlaces.sort(), Array.from({ length: 800 }, (_, index) =>
261+ createPlaceIdentity("BUS", `STATIC-${index}`).placeId).sort());
157262 let fileCount = 0;
158263 let totalBytes = 0;
159264 let maximum = 0;
@@ -4,8 +4,9 @@ import { existsSync, mkdtempSync, opendirSync, readFileSync, rmSync } from "node
4 import { tmpdir } from "node:os";4 import { tmpdir } from "node:os";
5 import { join } from "node:path";5 import { join } from "node:path";
6 import { DatabaseSync } from "node:sqlite";6 import { DatabaseSync } from "node:sqlite";
7-import { LIMITS, TRANSPORT_MODE } from "../../contracts/src/index.ts";7+import { LIMITS, TRANSPORT_MODE, isPlaceSearchItem } from "../../contracts/src/index.ts";
8 import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts";8 import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts";
9+import { SqliteCatalogReader } from "../src/catalog.ts";
9 import { publishStaticCatalog } from "../src/static-catalog.ts";10 import { publishStaticCatalog } from "../src/static-catalog.ts";
10 import { createCatalogClient } from "../../config-page/src/catalog-client.js";11 import { createCatalogClient } from "../../config-page/src/catalog-client.js";
11 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";12 import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js";
@@ -58,12 +59,22 @@ test("validated catalog publication preserves all modes, stable service identity
58 const options = await catalogFixture(t);59 const options = await catalogFixture(t);
59 const publication = publishStaticCatalog(options);60 const publication = publishStaticCatalog(options);
60 const client = pageClient(options.outputDirectory);61 const client = pageClient(options.outputDirectory);
62+ const reader = SqliteCatalogReader.open(options.catalogPath);
61 const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"];63 const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"];
62 const modes = new Set();64 const modes = new Set();
63 for (const query of selections) {65 for (const query of selections) {
64 for (const place of await client.searchPlaces(query)) {66 for (const place of await client.searchPlaces(query)) {
65 modes.add(place.mode);67 modes.add(place.mode);
66- for (const service of await client.listServices(place.placeId)) {68+ assert.equal(isPlaceSearchItem(place), true);
69+ assert.deepEqual(place, reader.searchPlaces(query).find((entry) => entry.placeId === place.placeId));
70+ const services = await client.listServices(place.placeId);
71+ const nativeLines = new Map(services.map((service) => [service.routing.lineRef, {
72+ lineLabel: service.lineLabel, lineColor: service.lineColor, lineTextColor: service.lineTextColor,
73+ }]));
74+ assert.equal(place.lines.length, nativeLines.size);
75+ for (const line of nativeLines.values()) assert.ok(place.lines.some((entry) =>
76+ entry.lineLabel === line.lineLabel && entry.lineColor === line.lineColor && entry.lineTextColor === line.lineTextColor));
77+ for (const service of services) {
67 assert.equal(service.lineMode, place.mode);78 assert.equal(service.lineMode, place.mode);
68 assert.deepEqual(await client.lookupService(service.serviceId), service);79 assert.deepEqual(await client.lookupService(service.serviceId), service);
69 }80 }
@@ -88,6 +99,83 @@ test("validated catalog publication preserves all modes, stable service identity
88 assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before);99 assert.deepEqual(readFileSync(join(options.outputDirectory, "manifest.json")), before);
89 });100 });
90 101
102+test("search publication keeps every native line in natural order and refreshes display metadata by revision", async (t) => {
103+ const options = await catalogFixture(t);
104+ const database = new DatabaseSync(options.catalogPath);
105+ const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
106+ const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
107+ const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
108+ const busLines = [
109+ { lineId: "C-UNI10", lineLabel: "10", lineColor: "#333333" },
110+ { lineId: "C-UNI2-B", lineLabel: "2", lineColor: "#222222" },
111+ { lineId: "C-UNI2-C", lineLabel: "2", lineColor: "#111111" },
112+ { lineId: "C-UNI2-A", lineLabel: "2", lineColor: "#111111" },
113+ ];
114+ try {
115+ database.exec("BEGIN");
116+ for (const [mode, lines] of [
117+ ["BUS", busLines],
118+ ["METRO", [{ lineId: "C-UNI13", lineLabel: "13", lineColor: "#82c8e6" }]],
119+ ]) {
120+ const place = createPlaceIdentity(mode, "STATIC-UNIVERSITY");
121+ insertPlace.run(place.placeId, "Saint-Denis Université", "Saint-Denis", mode, place.canonicalTuple);
122+ insertSearch.run(normalizeCatalogSearchText(`Saint-Denis Université ${mode}`), place.placeId);
123+ for (const line of lines) {
124+ for (const direction of ["0", "1"]) {
125+ const monitoringRef = `fixture:university:${mode}:${direction}`;
126+ const destinationRef = `fixture:university:terminal:${direction}`;
127+ const service = createServiceIdentity({ mode, lineId: line.lineId, monitoringRef, directionId: direction, destinationRef });
128+ insertService.run(
129+ service.serviceId, place.placeId, "Saint-Denis Université", line.lineLabel, `Terminus ${direction}`,
130+ line.lineColor, "#ffffff", monitoringRef, `IDFM:${line.lineId}`, direction, destinationRef, service.canonicalTuple,
131+ );
132+ }
133+ }
134+ }
135+ database.exec("COMMIT");
136+ } finally {
137+ database.close();
138+ }
139+
140+ const first = publishStaticCatalog(options);
141+ const requests = [];
142+ const oldClient = pageClient(options.outputDirectory, requests);
143+ const places = await oldClient.searchPlaces("universite");
144+ assert.equal(places.length, 2);
145+ assert.equal(places.every(isPlaceSearchItem), true);
146+ assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false);
147+ const expectedBus = [
148+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
149+ { lineLabel: "2", lineColor: "#222222", lineTextColor: "#ffffff" },
150+ { lineLabel: "2", lineColor: "#111111", lineTextColor: "#ffffff" },
151+ { lineLabel: "10", lineColor: "#333333", lineTextColor: "#ffffff" },
152+ ];
153+ assert.deepEqual(places.find((place) => place.mode === "BUS").lines, expectedBus);
154+ assert.deepEqual(places.find((place) => place.mode === "METRO").lines, [
155+ { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#ffffff" },
156+ ]);
157+ const live = SqliteCatalogReader.open(options.catalogPath).searchPlaces("universite");
158+ for (const place of places) assert.deepEqual(place, live.find((entry) => entry.placeId === place.placeId));
159+
160+ const update = new DatabaseSync(options.catalogPath);
161+ try {
162+ update.prepare("UPDATE services SET line_label = ?, line_color = ?, line_text_color = ? WHERE line_ref = ?")
163+ .run("3", "#abcdef", "#000000", "IDFM:C-UNI2-B");
164+ } finally {
165+ update.close();
166+ }
167+ const second = publishStaticCatalog(options);
168+ assert.equal(second.manifest.sourceRevision, first.manifest.sourceRevision);
169+ assert.notEqual(second.manifest.revision, first.manifest.revision);
170+ const refreshed = await pageClient(options.outputDirectory).searchPlaces("universite");
171+ assert.deepEqual(refreshed.find((place) => place.mode === "BUS").lines, [
172+ expectedBus[0], expectedBus[2],
173+ { lineLabel: "3", lineColor: "#abcdef", lineTextColor: "#000000" },
174+ expectedBus[3],
175+ ]);
176+ assert.deepEqual((await oldClient.searchPlaces("universite")).find((place) => place.mode === "BUS").lines, expectedBus);
177+});
178+
91 test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => {179 test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => {
92 const first = await catalogFixture(t);180 const first = await catalogFixture(t);
93 const oldPublication = publishStaticCatalog(first);181 const oldPublication = publishStaticCatalog(first);
@@ -129,9 +217,10 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
129 insertSearch.run("busy fixture", busy.placeId);217 insertSearch.run("busy fixture", busy.placeId);
130 for (let index = 0; index < 12; index += 1) {218 for (let index = 0; index < 12; index += 1) {
131 const destinationRef = `fixture:busy:${index}`;219 const destinationRef = `fixture:busy:${index}`;
132- const service = createServiceIdentity({ mode: "BUS", lineId: "C-BUSY", monitoringRef: longMonitoring, directionId: "0", destinationRef });220+ const lineId = `C-BUSY-${index}`;
221+ const service = createServiceIdentity({ mode: "BUS", lineId, monitoringRef: longMonitoring, directionId: "0", destinationRef });
133 longServiceId ??= service.serviceId;222 longServiceId ??= service.serviceId;
134- insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, "IDFM:C-BUSY", "0", destinationRef, service.canonicalTuple);223+ insertService.run(service.serviceId, busy.placeId, "Busy fixture", String(index).padStart(2, "0"), "Fixture terminal", "#123456", "#ffffff", longMonitoring, `IDFM:${lineId}`, "0", destinationRef, service.canonicalTuple);
135 }224 }
136 database.exec("COMMIT");225 database.exec("COMMIT");
137 } finally {226 } finally {
@@ -147,6 +236,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
147 const client = pageClient(options.outputDirectory, requests);236 const client = pageClient(options.outputDirectory, requests);
148 const matches = await client.searchPlaces("café");237 const matches = await client.searchPlaces("café");
149 assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`));238 assert.deepEqual(matches.map((place) => place.stopLabel.slice(0, 9)), Array.from({ length: 20 }, (_, index) => `Café ${String(index).padStart(4, "0")}`));
239+ assert.equal(requests.some((url) => /\/(?:places|services)\//u.test(url)), false);
240+ for (const place of matches) assert.deepEqual(place.lines, [
241+ { lineLabel: "99", lineColor: "#123456", lineTextColor: "#ffffff" },
242+ ]);
243+ const busyMatches = await client.searchPlaces("busy");
244+ assert.deepEqual(busyMatches[0].lines, Array.from({ length: 12 }, (_, index) => ({
245+ lineLabel: String(index).padStart(2, "0"), lineColor: "#123456", lineTextColor: "#ffffff",
246+ })));
150 const services = await client.listServices(busy.placeId);247 const services = await client.listServices(busy.placeId);
151 assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0")));248 assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0")));
152 assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring);249 assert.equal((await client.lookupService(longServiceId)).routing.monitoringRef, longMonitoring);
@@ -154,6 +251,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop
154 assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true);251 assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true);
155 assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId));252 assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId));
156 assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true);253 assert.equal(requests.some((url) => url.includes("/search/63/1.json")), true);
254+ const exportedPlaces = [];
255+ for (let page = 0; page !== null;) {
256+ const body = JSON.parse(readFileSync(join(options.outputDirectory, revision, "search", "63_61", `${page}.json`), "utf8"));
257+ exportedPlaces.push(...body.places.map((place) => place.placeId));
258+ page = body.nextPage;
259+ }
260+ assert.deepEqual(exportedPlaces.sort(), Array.from({ length: 800 }, (_, index) =>
261+ createPlaceIdentity("BUS", `STATIC-${index}`).placeId).sort());
157 let fileCount = 0;262 let fileCount = 0;
158 let totalBytes = 0;263 let totalBytes = 0;
159 let maximum = 0;264 let maximum = 0;
added packages/config-page/assets/lapin-fute-1024.jpg +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differBinary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.jpg differ
added packages/config-page/assets/lapin-fute-1024.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differBinary files /dev/null and b/packages/config-page/assets/lapin-fute-1024.png differ
added packages/config-page/assets/lapin-fute.svg +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="lapin-fute-title lapin-fute-desc">
2+ <title id="lapin-fute-title">Lapin Futé</title>
3+ <desc id="lapin-fute-desc">Un billet rouge arrondi à deux encoches, percé d'un trajet en transparence qui dessine les oreilles et les joues du lapin et se termine par deux arrêts ronds.</desc>
4+ <defs>
5+ <mask id="lapin-fute-cutout" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
6+ <rect width="64" height="64" fill="#ffffff"/>
7+ <path fill="none" stroke="#000000" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" d="M25 50C19.6 48.8 16.5 45.5 16.5 41.3C16.5 37.4 19 34.3 22 32L18.8 18.5C18.1 15 19.5 13 22 13C25 13 26.6 16.9 27.1 21.3L28.5 30.5Q32 29.5 35.5 30.5L36.9 21.3C37.4 16.9 39 13 42 13C44.5 13 45.9 15 45.2 18.5L42 32C45 34.3 47.5 37.4 47.5 41.3C47.5 45.5 44.4 48.8 39 50"/>
8+ <g fill="#000000">
9+ <circle cx="25" cy="50" r="3.5"/>
10+ <circle cx="39" cy="50" r="3.5"/>
11+ </g>
12+ </mask>
13+ </defs>
14+ <path fill="#fa4a36" mask="url(#lapin-fute-cutout)" d="M15 4H49A11 11 0 0 1 60 15V26A6 6 0 0 0 60 38V49A11 11 0 0 1 49 60H15A11 11 0 0 1 4 49V38A6 6 0 0 0 4 26V15A11 11 0 0 1 15 4Z"/>
15+</svg>
new file mode 100644
@@ -0,0 +1,15 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="lapin-fute-title lapin-fute-desc">
2+ <title id="lapin-fute-title">Lapin Futé</title>
3+ <desc id="lapin-fute-desc">Un billet rouge arrondi à deux encoches, percé d'un trajet en transparence qui dessine les oreilles et les joues du lapin et se termine par deux arrêts ronds.</desc>
4+ <defs>
5+ <mask id="lapin-fute-cutout" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
6+ <rect width="64" height="64" fill="#ffffff"/>
7+ <path fill="none" stroke="#000000" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" d="M25 50C19.6 48.8 16.5 45.5 16.5 41.3C16.5 37.4 19 34.3 22 32L18.8 18.5C18.1 15 19.5 13 22 13C25 13 26.6 16.9 27.1 21.3L28.5 30.5Q32 29.5 35.5 30.5L36.9 21.3C37.4 16.9 39 13 42 13C44.5 13 45.9 15 45.2 18.5L42 32C45 34.3 47.5 37.4 47.5 41.3C47.5 45.5 44.4 48.8 39 50"/>
8+ <g fill="#000000">
9+ <circle cx="25" cy="50" r="3.5"/>
10+ <circle cx="39" cy="50" r="3.5"/>
11+ </g>
12+ </mask>
13+ </defs>
14+ <path fill="#fa4a36" mask="url(#lapin-fute-cutout)" d="M15 4H49A11 11 0 0 1 60 15V26A6 6 0 0 0 60 38V49A11 11 0 0 1 49 60H15A11 11 0 0 1 4 49V38A6 6 0 0 0 4 26V15A11 11 0 0 1 15 4Z"/>
15+</svg>
modified packages/config-page/index.html +116 -63
@@ -7,88 +7,141 @@
77 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
88 <title>Lapin Futé</title>
99 <link rel="stylesheet" href="./styles/config-page.css">
10+ <link rel="icon" type="image/svg+xml" href="./assets/lapin-fute.svg">
1011 </head>
1112 <body>
12- <header class="masthead">
13- <div class="route-mark" aria-hidden="true">LF</div>
14- <div>
15- <h1 id="page-title">Lapin Futé</h1>
16- <p id="intro"></p>
13+ <header class="app-header">
14+ <div class="app-brand">
15+ <img class="brand-mark" src="./assets/lapin-fute.svg" alt="" width="64" height="64">
16+ <span>Lapin Futé</span>
1717 </div>
1818 </header>
1919
2020 <main>
2121 <div id="config-view">
22- <section aria-labelledby="key-title">
23- <div class="section-heading">
24- <span class="step" aria-hidden="true">01</span>
25- <h2 id="key-title">PRIM access</h2>
26- </div>
27- <p class="status" id="key-status"></p>
28- <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p>
29- <label class="field-label" for="key-input" id="key-label"></label>
30- <div class="key-row">
31- <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
32- <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>
22+ <div class="page-heading">
23+ <h1 id="page-title">Settings</h1>
24+ <p id="intro"></p>
25+ </div>
26+ <div class="settings-list">
27+ <details class="settings-section" id="prim-section" aria-labelledby="key-title" open>
28+ <summary>
29+ <span class="section-icon" aria-hidden="true">
30+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="8" cy="9" r="4"></circle><path d="m11 12 8 8m-3-3 3-3m-6 0 3-3"></path></svg>
31+ </span>
32+ <span class="summary-copy">
33+ <h2 id="key-title">PRIM access</h2>
34+ <span class="summary-hint" id="key-required-hint" hidden></span>
35+ </span>
36+ <span class="disclosure" aria-hidden="true"></span>
37+ </summary>
38+ <div class="section-body">
39+ <p class="requirement" id="key-required" hidden></p>
40+ <div class="key-links">
41+ <a id="key-link" class="link-action" href="https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification" target="_blank" rel="noopener noreferrer"></a>
42+ <a id="key-docs-link" class="link-secondary" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a>
43+ </div>
44+ <label class="field-label" for="key-input" id="key-label"></label>
45+ <div class="key-row">
46+ <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
47+ <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>
48+ </div>
49+ <p class="error" id="key-error" role="alert" hidden></p>
50+ <p class="pending" id="key-pending" role="status" hidden></p>
51+ <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
52+ <div class="key-note">
53+ <p class="status" id="key-status"></p>
54+ <p class="support" id="key-explanation"></p>
55+ </div>
3356 </div>
34- <p class="error" id="key-error" role="alert" hidden></p>
35- <p class="pending" id="key-pending" role="status" hidden></p>
36- <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
37- </section>
57+ </details>
3858
39- <section aria-labelledby="favorites-title">
40- <div class="section-heading">
41- <span class="step" aria-hidden="true">02</span>
42- <h2 id="favorites-title">Favorite departures</h2>
59+ <details class="settings-section" id="favorites-section" aria-labelledby="favorites-title">
60+ <summary>
61+ <span class="section-icon" aria-hidden="true">
62+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>
63+ </span>
64+ <span class="summary-copy">
65+ <h2 id="favorites-title">Favorite departures</h2>
66+ <span class="favorite-capacity" id="favorites-count" aria-live="polite"></span>
67+ </span>
68+ <span class="disclosure" aria-hidden="true"></span>
69+ </summary>
70+ <div class="section-body">
71+ <p class="empty-state" id="favorites-empty" hidden></p>
72+ <p class="error" id="favorites-status" role="status" hidden></p>
73+ <ol id="favorites-list" class="favorites"></ol>
4374 </div>
44- <p class="status" id="favorites-empty" hidden></p>
45- <ol id="favorites-list" class="favorites"></ol>
46- </section>
75+ </details>
4776
48- <section aria-labelledby="add-title" id="add-section">
49- <div class="section-heading">
50- <span class="step" aria-hidden="true">03</span>
51- <h2 id="add-title">Add a favorite</h2>
52- </div>
53- <label class="field-label" for="place-search" id="search-label"></label>
54- <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false">
55- <p class="support" id="search-hint"></p>
56- <p id="catalog-status" class="status" role="status" aria-live="polite"></p>
57- <ul id="place-results" class="choice-list"></ul>
77+ <details class="settings-section" id="add-section" aria-labelledby="add-title">
78+ <summary>
79+ <span class="section-icon" aria-hidden="true">
80+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><path d="M12 5v14M5 12h14"></path></svg>
81+ </span>
82+ <span class="summary-copy">
83+ <h2 id="add-title">Add a favorite</h2>
84+ <span class="section-caption" id="add-caption"></span>
85+ </span>
86+ <span class="disclosure" aria-hidden="true"></span>
87+ </summary>
88+ <div class="section-body">
89+ <label class="field-label" for="place-search" id="search-label"></label>
90+ <div class="search-field">
91+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="10.5" cy="10.5" r="6.5"></circle><path d="m16 16 4.5 4.5"></path></svg>
92+ <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false" aria-describedby="search-hint">
93+ </div>
94+ <p class="support" id="search-hint"></p>
95+ <p id="catalog-status" class="status" role="status" aria-live="polite"></p>
96+ <ul id="place-results" class="choice-list"></ul>
5897
59- <div id="service-step" hidden>
60- <label class="field-label" for="service-select" id="services-label"></label>
61- <select id="service-select"></select>
62- </div>
98+ <div id="service-step" hidden>
99+ <label class="field-label" for="service-select" id="services-label"></label>
100+ <select id="service-select"></select>
101+ </div>
63102
64- <div id="preview" class="preview" hidden>
65- <p class="eyebrow" id="preview-title"></p>
66- <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p>
67- <p id="preview-destination" class="destination"></p>
68- <p id="preview-departures" class="recorded-preview"></p>
69- <label class="field-label" for="favorite-name" id="favorite-name-label"></label>
70- <input id="favorite-name" type="text" autocomplete="off" maxlength="96">
71- <button type="button" id="favorite-add" class="secondary"></button>
103+ <div id="preview" class="preview" hidden>
104+ <p class="eyebrow" id="preview-title"></p>
105+ <div class="preview-route">
106+ <span id="preview-line" class="line-badge"></span>
107+ <div class="route-journey">
108+ <strong id="preview-stop" class="route-stop"></strong>
109+ <span id="preview-destination" class="route-destination"></span>
110+ </div>
111+ </div>
112+ <p id="preview-departures" class="recorded-preview"></p>
113+ <label class="field-label" for="favorite-name" id="favorite-name-label"></label>
114+ <input id="favorite-name" type="text" autocomplete="off" maxlength="96">
115+ <button type="button" id="favorite-add" class="secondary"></button>
116+ </div>
72117 </div>
73- </section>
118+ </details>
74119
75- <section aria-labelledby="sync-title" id="sync-section">
76- <div class="section-heading">
77- <span class="step" aria-hidden="true">04</span>
78- <h2 id="sync-title">Watch synchronization</h2>
120+ <details class="settings-section" id="sync-section" aria-labelledby="sync-title">
121+ <summary>
122+ <span class="section-icon" aria-hidden="true">
123+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><rect x="6" y="5" width="12" height="14" rx="4"></rect><path d="M9 5V2h6v3M9 19v3h6v-3m-6-9h6m-3-3 3 3-3 3"></path></svg>
124+ </span>
125+ <span class="summary-copy">
126+ <h2 id="sync-title">Watch synchronization</h2>
127+ <span class="section-caption" id="sync-caption"></span>
128+ </span>
129+ <span class="disclosure" aria-hidden="true"></span>
130+ </summary>
131+ <div class="section-body">
132+ <label class="check-row" for="force-sync">
133+ <input id="force-sync" type="checkbox">
134+ <span id="force-sync-label"></span>
135+ </label>
136+ <p class="support" id="sync-hint"></p>
79137 </div>
80- <label class="check-row" for="force-sync">
81- <input id="force-sync" type="checkbox">
82- <span id="force-sync-label"></span>
83- </label>
84- <p class="support" id="sync-hint"></p>
85- </section>
138+ </details>
139+ </div>
86140 </div>
87141
88142 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
89- <button type="button" id="about-back" class="quiet"></button>
90- <div class="section-heading about-heading">
91- <span class="step" aria-hidden="true">LF</span>
143+ <button type="button" id="about-back" class="back-link quiet"></button>
144+ <div class="about-heading">
92145 <h2 id="about-title"></h2>
93146 </div>
94147
@@ -7,88 +7,141 @@
7 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">7 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
8 <title>Lapin Futé</title>8 <title>Lapin Futé</title>
9 <link rel="stylesheet" href="./styles/config-page.css">9 <link rel="stylesheet" href="./styles/config-page.css">
10+ <link rel="icon" type="image/svg+xml" href="./assets/lapin-fute.svg">
10 </head>11 </head>
11 <body>12 <body>
12- <header class="masthead">13+ <header class="app-header">
13- <div class="route-mark" aria-hidden="true">LF</div>14+ <div class="app-brand">
14- <div>15+ <img class="brand-mark" src="./assets/lapin-fute.svg" alt="" width="64" height="64">
15- <h1 id="page-title">Lapin Futé</h1>16+ <span>Lapin Futé</span>
16- <p id="intro"></p>
17 </div>17 </div>
18 </header>18 </header>
19 19
20 <main>20 <main>
21 <div id="config-view">21 <div id="config-view">
22- <section aria-labelledby="key-title">22+ <div class="page-heading">
23- <div class="section-heading">23+ <h1 id="page-title">Settings</h1>
24- <span class="step" aria-hidden="true">01</span>24+ <p id="intro"></p>
25- <h2 id="key-title">PRIM access</h2>25+ </div>
26- </div>26+ <div class="settings-list">
27- <p class="status" id="key-status"></p>27+ <details class="settings-section" id="prim-section" aria-labelledby="key-title" open>
28- <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p>28+ <summary>
29- <label class="field-label" for="key-input" id="key-label"></label>29+ <span class="section-icon" aria-hidden="true">
30- <div class="key-row">30+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="8" cy="9" r="4"></circle><path d="m11 12 8 8m-3-3 3-3m-6 0 3-3"></path></svg>
31- <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">31+ </span>
32- <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>32+ <span class="summary-copy">
33+ <h2 id="key-title">PRIM access</h2>
34+ <span class="summary-hint" id="key-required-hint" hidden></span>
35+ </span>
36+ <span class="disclosure" aria-hidden="true"></span>
37+ </summary>
38+ <div class="section-body">
39+ <p class="requirement" id="key-required" hidden></p>
40+ <div class="key-links">
41+ <a id="key-link" class="link-action" href="https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification" target="_blank" rel="noopener noreferrer"></a>
42+ <a id="key-docs-link" class="link-secondary" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a>
43+ </div>
44+ <label class="field-label" for="key-input" id="key-label"></label>
45+ <div class="key-row">
46+ <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
47+ <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>
48+ </div>
49+ <p class="error" id="key-error" role="alert" hidden></p>
50+ <p class="pending" id="key-pending" role="status" hidden></p>
51+ <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
52+ <div class="key-note">
53+ <p class="status" id="key-status"></p>
54+ <p class="support" id="key-explanation"></p>
55+ </div>
33 </div>56 </div>
34- <p class="error" id="key-error" role="alert" hidden></p>57+ </details>
35- <p class="pending" id="key-pending" role="status" hidden></p>
36- <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
37- </section>
38 58
39- <section aria-labelledby="favorites-title">59+ <details class="settings-section" id="favorites-section" aria-labelledby="favorites-title">
40- <div class="section-heading">60+ <summary>
41- <span class="step" aria-hidden="true">02</span>61+ <span class="section-icon" aria-hidden="true">
42- <h2 id="favorites-title">Favorite departures</h2>62+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9L12 3Z"></path></svg>
63+ </span>
64+ <span class="summary-copy">
65+ <h2 id="favorites-title">Favorite departures</h2>
66+ <span class="favorite-capacity" id="favorites-count" aria-live="polite"></span>
67+ </span>
68+ <span class="disclosure" aria-hidden="true"></span>
69+ </summary>
70+ <div class="section-body">
71+ <p class="empty-state" id="favorites-empty" hidden></p>
72+ <p class="error" id="favorites-status" role="status" hidden></p>
73+ <ol id="favorites-list" class="favorites"></ol>
43 </div>74 </div>
44- <p class="status" id="favorites-empty" hidden></p>75+ </details>
45- <ol id="favorites-list" class="favorites"></ol>
46- </section>
47 76
48- <section aria-labelledby="add-title" id="add-section">77+ <details class="settings-section" id="add-section" aria-labelledby="add-title">
49- <div class="section-heading">78+ <summary>
50- <span class="step" aria-hidden="true">03</span>79+ <span class="section-icon" aria-hidden="true">
51- <h2 id="add-title">Add a favorite</h2>80+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><path d="M12 5v14M5 12h14"></path></svg>
52- </div>81+ </span>
53- <label class="field-label" for="place-search" id="search-label"></label>82+ <span class="summary-copy">
54- <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false">83+ <h2 id="add-title">Add a favorite</h2>
55- <p class="support" id="search-hint"></p>84+ <span class="section-caption" id="add-caption"></span>
56- <p id="catalog-status" class="status" role="status" aria-live="polite"></p>85+ </span>
57- <ul id="place-results" class="choice-list"></ul>86+ <span class="disclosure" aria-hidden="true"></span>
87+ </summary>
88+ <div class="section-body">
89+ <label class="field-label" for="place-search" id="search-label"></label>
90+ <div class="search-field">
91+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="10.5" cy="10.5" r="6.5"></circle><path d="m16 16 4.5 4.5"></path></svg>
92+ <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false" aria-describedby="search-hint">
93+ </div>
94+ <p class="support" id="search-hint"></p>
95+ <p id="catalog-status" class="status" role="status" aria-live="polite"></p>
96+ <ul id="place-results" class="choice-list"></ul>
58 97
59- <div id="service-step" hidden>98+ <div id="service-step" hidden>
60- <label class="field-label" for="service-select" id="services-label"></label>99+ <label class="field-label" for="service-select" id="services-label"></label>
61- <select id="service-select"></select>100+ <select id="service-select"></select>
62- </div>101+ </div>
63 102
64- <div id="preview" class="preview" hidden>103+ <div id="preview" class="preview" hidden>
65- <p class="eyebrow" id="preview-title"></p>104+ <p class="eyebrow" id="preview-title"></p>
66- <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p>105+ <div class="preview-route">
67- <p id="preview-destination" class="destination"></p>106+ <span id="preview-line" class="line-badge"></span>
68- <p id="preview-departures" class="recorded-preview"></p>107+ <div class="route-journey">
69- <label class="field-label" for="favorite-name" id="favorite-name-label"></label>108+ <strong id="preview-stop" class="route-stop"></strong>
70- <input id="favorite-name" type="text" autocomplete="off" maxlength="96">109+ <span id="preview-destination" class="route-destination"></span>
71- <button type="button" id="favorite-add" class="secondary"></button>110+ </div>
111+ </div>
112+ <p id="preview-departures" class="recorded-preview"></p>
113+ <label class="field-label" for="favorite-name" id="favorite-name-label"></label>
114+ <input id="favorite-name" type="text" autocomplete="off" maxlength="96">
115+ <button type="button" id="favorite-add" class="secondary"></button>
116+ </div>
72 </div>117 </div>
73- </section>118+ </details>
74 119
75- <section aria-labelledby="sync-title" id="sync-section">120+ <details class="settings-section" id="sync-section" aria-labelledby="sync-title">
76- <div class="section-heading">121+ <summary>
77- <span class="step" aria-hidden="true">04</span>122+ <span class="section-icon" aria-hidden="true">
78- <h2 id="sync-title">Watch synchronization</h2>123+ <svg viewBox="0 0 24 24" fill="none" focusable="false"><rect x="6" y="5" width="12" height="14" rx="4"></rect><path d="M9 5V2h6v3M9 19v3h6v-3m-6-9h6m-3-3 3 3-3 3"></path></svg>
124+ </span>
125+ <span class="summary-copy">
126+ <h2 id="sync-title">Watch synchronization</h2>
127+ <span class="section-caption" id="sync-caption"></span>
128+ </span>
129+ <span class="disclosure" aria-hidden="true"></span>
130+ </summary>
131+ <div class="section-body">
132+ <label class="check-row" for="force-sync">
133+ <input id="force-sync" type="checkbox">
134+ <span id="force-sync-label"></span>
135+ </label>
136+ <p class="support" id="sync-hint"></p>
79 </div>137 </div>
80- <label class="check-row" for="force-sync">138+ </details>
81- <input id="force-sync" type="checkbox">139+ </div>
82- <span id="force-sync-label"></span>
83- </label>
84- <p class="support" id="sync-hint"></p>
85- </section>
86 </div>140 </div>
87 141
88 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>142 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
89- <button type="button" id="about-back" class="quiet"></button>143+ <button type="button" id="about-back" class="back-link quiet"></button>
90- <div class="section-heading about-heading">144+ <div class="about-heading">
91- <span class="step" aria-hidden="true">LF</span>
92 <h2 id="about-title"></h2>145 <h2 id="about-title"></h2>
93 </div>146 </div>
94 147
modified packages/config-page/src/config-core.js +46 -5
@@ -71,12 +71,16 @@ export function selectLocale(language) {
7171 export const COPY = {
7272 en: {
7373 pageTitle: "Lapin Futé settings",
74+ pageHeading: "Settings",
7475 intro: "Choose the departures you want at hand on your Pebble.",
7576 keyTitle: "PRIM access",
76- keyStatusConfigured: "A key is configured on this phone.",
77+ keyStatusConfigured: "A key is saved on this phone. It is not verified against PRIM.",
7778 keyStatusMissing: "No key is configured.",
7879 keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.",
7980 keyLink: "Create a PRIM token",
81+ keyDocsLink: "Instructions",
82+ keyRequirement: "A personal PRIM token is required to retrieve live departures. You can prepare your favorites without one.",
83+ keyRequiredHint: "PRIM token required for live departures",
8084 keyLabel: "Replace with a new key",
8185 keyPlaceholder: "Paste a new key",
8286 keyShow: "Show",
@@ -88,8 +92,13 @@ export const COPY = {
8892 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
8993 keyErrorNewline: "The key must not contain control characters.",
9094 favoritesTitle: "Favorite departures",
95+ favoriteUsed: "used",
96+ favoritesUsed: "used",
97+ favoriteAvailable: "available",
98+ favoritesAvailable: "available",
9199 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
92100 favoriteAddTitle: "Add a favorite",
101+ favoriteAddCaption: "Stop, line and direction",
93102 searchLabel: "Stop or station",
94103 searchPlaceholder: "Search by name",
95104 searchHint: "Enter at least 2 characters.",
@@ -118,15 +127,20 @@ export const COPY = {
118127 aboutBack: "Back to settings",
119128 aboutTitle: "About Lapin Futé",
120129 syncTitle: "Watch synchronization",
130+ syncCaption: "Transfer options",
121131 syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.",
122132 forceSyncLabel: "Force a full synchronization at the next save",
123133 saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.",
124134 },
125135 fr: {
126136 pageTitle: "Réglages Lapin Futé",
137+ pageHeading: "Réglages",
127138 intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.",
128139 keyTitle: "Accès PRIM",
129- keyStatusConfigured: "Une clé est configurée sur ce téléphone.",
140+ keyStatusConfigured: "Une clé est enregistrée sur ce téléphone. Elle n’est pas vérifiée auprès de PRIM.",
141+ keyDocsLink: "Instructions",
142+ keyRequirement: "Un jeton PRIM personnel est obligatoire pour récupérer les prochains départs en temps réel. Vous pouvez préparer vos favoris sans jeton.",
143+ keyRequiredHint: "Jeton PRIM requis pour les départs en temps réel",
130144 keyStatusMissing: "Aucune clé n’est configurée.",
131145 keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.",
132146 keyLink: "Créer un jeton PRIM",
@@ -141,8 +155,13 @@ export const COPY = {
141155 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
142156 keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
143157 favoritesTitle: "Départs favoris",
158+ favoriteUsed: "utilisé",
159+ favoritesUsed: "utilisés",
160+ favoriteAvailable: "disponible",
161+ favoritesAvailable: "disponibles",
144162 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
145163 favoriteAddTitle: "Ajouter un favori",
164+ favoriteAddCaption: "Arrêt, ligne et direction",
146165 searchLabel: "Arrêt ou gare",
147166 searchPlaceholder: "Rechercher par nom",
148167 searchHint: "Saisissez au moins 2 caractères.",
@@ -171,6 +190,7 @@ export const COPY = {
171190 aboutBack: "Retour aux réglages",
172191 aboutTitle: "À propos de Lapin Futé",
173192 syncTitle: "Synchronisation de la montre",
193+ syncCaption: "Options de transfert",
174194 syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.",
175195 forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement",
176196 saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.",
@@ -238,7 +258,8 @@ export function copyPhoneFavorite(favorite) {
238258 return copy;
239259 }
240260
241-const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"];
261+const PLACE_LINE_FIELDS = ["lineLabel", "lineColor", "lineTextColor"];
262+const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode", "lines"];
242263 const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"];
243264 const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"];
244265
@@ -248,12 +269,32 @@ function exactFields(value, allowed, required) {
248269 && required.every((field) => Object.hasOwn(value, field));
249270 }
250271
272+export function isPlaceLine(value) {
273+ return exactFields(value, PLACE_LINE_FIELDS, PLACE_LINE_FIELDS)
274+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
275+ && isLineColor(value.lineColor)
276+ && isLineColor(value.lineTextColor);
277+}
278+
279+// Producer-precomputed display lines for one search row: an entry per native
280+// line with no per-line mode or ref. The list is required and nonempty; the
281+// response byte bound (not a speculative count cap) limits its length. Holes
282+// are visited by for...of, which Array.prototype.every would silently skip.
283+function isPlaceLineList(lines) {
284+ if (!Array.isArray(lines) || lines.length === 0) return false;
285+ for (const line of lines) {
286+ if (!isPlaceLine(line)) return false;
287+ }
288+ return true;
289+}
290+
251291 export function isPlaceSearchItem(value) {
252- return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"])
292+ return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode", "lines"])
253293 && boundedString(value.placeId, LIMITS.idUtf8Bytes)
254294 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
255295 && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes))
256- && TRANSPORT_MODES.includes(value.mode);
296+ && TRANSPORT_MODES.includes(value.mode)
297+ && isPlaceLineList(value.lines);
257298 }
258299
259300 export function isPlaceSearchResult(value) {
@@ -71,12 +71,16 @@ export function selectLocale(language) {
71 export const COPY = {71 export const COPY = {
72 en: {72 en: {
73 pageTitle: "Lapin Futé settings",73 pageTitle: "Lapin Futé settings",
74+ pageHeading: "Settings",
74 intro: "Choose the departures you want at hand on your Pebble.",75 intro: "Choose the departures you want at hand on your Pebble.",
75 keyTitle: "PRIM access",76 keyTitle: "PRIM access",
76- keyStatusConfigured: "A key is configured on this phone.",77+ keyStatusConfigured: "A key is saved on this phone. It is not verified against PRIM.",
77 keyStatusMissing: "No key is configured.",78 keyStatusMissing: "No key is configured.",
78 keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.",79 keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.",
79 keyLink: "Create a PRIM token",80 keyLink: "Create a PRIM token",
81+ keyDocsLink: "Instructions",
82+ keyRequirement: "A personal PRIM token is required to retrieve live departures. You can prepare your favorites without one.",
83+ keyRequiredHint: "PRIM token required for live departures",
80 keyLabel: "Replace with a new key",84 keyLabel: "Replace with a new key",
81 keyPlaceholder: "Paste a new key",85 keyPlaceholder: "Paste a new key",
82 keyShow: "Show",86 keyShow: "Show",
@@ -88,8 +92,13 @@ export const COPY = {
88 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",92 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
89 keyErrorNewline: "The key must not contain control characters.",93 keyErrorNewline: "The key must not contain control characters.",
90 favoritesTitle: "Favorite departures",94 favoritesTitle: "Favorite departures",
95+ favoriteUsed: "used",
96+ favoritesUsed: "used",
97+ favoriteAvailable: "available",
98+ favoritesAvailable: "available",
91 favoritesEmpty: "No favorites yet. Search for a stop to add one.",99 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
92 favoriteAddTitle: "Add a favorite",100 favoriteAddTitle: "Add a favorite",
101+ favoriteAddCaption: "Stop, line and direction",
93 searchLabel: "Stop or station",102 searchLabel: "Stop or station",
94 searchPlaceholder: "Search by name",103 searchPlaceholder: "Search by name",
95 searchHint: "Enter at least 2 characters.",104 searchHint: "Enter at least 2 characters.",
@@ -118,15 +127,20 @@ export const COPY = {
118 aboutBack: "Back to settings",127 aboutBack: "Back to settings",
119 aboutTitle: "About Lapin Futé",128 aboutTitle: "About Lapin Futé",
120 syncTitle: "Watch synchronization",129 syncTitle: "Watch synchronization",
130+ syncCaption: "Transfer options",
121 syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.",131 syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.",
122 forceSyncLabel: "Force a full synchronization at the next save",132 forceSyncLabel: "Force a full synchronization at the next save",
123 saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.",133 saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.",
124 },134 },
125 fr: {135 fr: {
126 pageTitle: "Réglages Lapin Futé",136 pageTitle: "Réglages Lapin Futé",
137+ pageHeading: "Réglages",
127 intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.",138 intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.",
128 keyTitle: "Accès PRIM",139 keyTitle: "Accès PRIM",
129- keyStatusConfigured: "Une clé est configurée sur ce téléphone.",140+ keyStatusConfigured: "Une clé est enregistrée sur ce téléphone. Elle n’est pas vérifiée auprès de PRIM.",
141+ keyDocsLink: "Instructions",
142+ keyRequirement: "Un jeton PRIM personnel est obligatoire pour récupérer les prochains départs en temps réel. Vous pouvez préparer vos favoris sans jeton.",
143+ keyRequiredHint: "Jeton PRIM requis pour les départs en temps réel",
130 keyStatusMissing: "Aucune clé n’est configurée.",144 keyStatusMissing: "Aucune clé n’est configurée.",
131 keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.",145 keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.",
132 keyLink: "Créer un jeton PRIM",146 keyLink: "Créer un jeton PRIM",
@@ -141,8 +155,13 @@ export const COPY = {
141 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",155 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
142 keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",156 keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
143 favoritesTitle: "Départs favoris",157 favoritesTitle: "Départs favoris",
158+ favoriteUsed: "utilisé",
159+ favoritesUsed: "utilisés",
160+ favoriteAvailable: "disponible",
161+ favoritesAvailable: "disponibles",
144 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",162 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
145 favoriteAddTitle: "Ajouter un favori",163 favoriteAddTitle: "Ajouter un favori",
164+ favoriteAddCaption: "Arrêt, ligne et direction",
146 searchLabel: "Arrêt ou gare",165 searchLabel: "Arrêt ou gare",
147 searchPlaceholder: "Rechercher par nom",166 searchPlaceholder: "Rechercher par nom",
148 searchHint: "Saisissez au moins 2 caractères.",167 searchHint: "Saisissez au moins 2 caractères.",
@@ -171,6 +190,7 @@ export const COPY = {
171 aboutBack: "Retour aux réglages",190 aboutBack: "Retour aux réglages",
172 aboutTitle: "À propos de Lapin Futé",191 aboutTitle: "À propos de Lapin Futé",
173 syncTitle: "Synchronisation de la montre",192 syncTitle: "Synchronisation de la montre",
193+ syncCaption: "Options de transfert",
174 syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.",194 syncHint: "La montre ne reçoit normalement que les changements. Cochez cette case pour tout renvoyer si la montre affiche des favoris obsolètes.",
175 forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement",195 forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement",
176 saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.",196 saveTooLarge: "Ces réglages sont trop volumineux pour être transférés. Supprimez le favori comportant l’entrée la plus longue, puis rajoutez-le.",
@@ -238,7 +258,8 @@ export function copyPhoneFavorite(favorite) {
238 return copy;258 return copy;
239 }259 }
240 260
241-const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"];261+const PLACE_LINE_FIELDS = ["lineLabel", "lineColor", "lineTextColor"];
262+const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode", "lines"];
242 const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"];263 const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"];
243 const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"];264 const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"];
244 265
@@ -248,12 +269,32 @@ function exactFields(value, allowed, required) {
248 && required.every((field) => Object.hasOwn(value, field));269 && required.every((field) => Object.hasOwn(value, field));
249 }270 }
250 271
272+export function isPlaceLine(value) {
273+ return exactFields(value, PLACE_LINE_FIELDS, PLACE_LINE_FIELDS)
274+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
275+ && isLineColor(value.lineColor)
276+ && isLineColor(value.lineTextColor);
277+}
278+
279+// Producer-precomputed display lines for one search row: an entry per native
280+// line with no per-line mode or ref. The list is required and nonempty; the
281+// response byte bound (not a speculative count cap) limits its length. Holes
282+// are visited by for...of, which Array.prototype.every would silently skip.
283+function isPlaceLineList(lines) {
284+ if (!Array.isArray(lines) || lines.length === 0) return false;
285+ for (const line of lines) {
286+ if (!isPlaceLine(line)) return false;
287+ }
288+ return true;
289+}
290+
251 export function isPlaceSearchItem(value) {291 export function isPlaceSearchItem(value) {
252- return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"])292+ return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode", "lines"])
253 && boundedString(value.placeId, LIMITS.idUtf8Bytes)293 && boundedString(value.placeId, LIMITS.idUtf8Bytes)
254 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)294 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
255 && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes))295 && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes))
256- && TRANSPORT_MODES.includes(value.mode);296+ && TRANSPORT_MODES.includes(value.mode)
297+ && isPlaceLineList(value.lines);
257 }298 }
258 299
259 export function isPlaceSearchResult(value) {300 export function isPlaceSearchResult(value) {
modified packages/config-page/src/config-page.js +230 -31
@@ -22,14 +22,16 @@ function byId(id) {
2222 }
2323
2424 const elements = Object.fromEntries([
25- "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label",
26- "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title",
27- "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint",
28- "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview",
29- "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures",
30- "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view",
31- "sync-title", "sync-hint", "force-sync-label", "force-sync",
32- "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr",
25+ "page-title", "intro", "prim-section", "key-title", "key-status", "key-required", "key-explanation",
26+ "key-link", "key-docs-link", "key-required-hint", "key-label", "key-input", "key-toggle", "key-error",
27+ "key-pending", "key-remove", "favorites-section", "favorites-title", "favorites-count", "favorites-status", "favorites-empty",
28+ "favorites-list", "add-title", "search-label", "place-search", "search-hint", "catalog-status",
29+ "place-results", "service-step", "services-label", "service-select", "preview", "preview-title",
30+ "preview-line", "preview-stop", "preview-destination", "preview-departures", "favorite-name-label",
31+ "favorite-name", "favorite-add", "add-section", "save", "config-view", "sync-section", "sync-title", "sync-hint",
32+ "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back",
33+ "about-title", "about-en", "about-fr",
34+ "add-caption", "sync-caption",
3335 ].map((id) => [id.replaceAll("-", "_"), byId(id)]));
3436
3537 const opening = parseConfigFragment(window.location.hash);
@@ -53,9 +55,13 @@ function setText(element, value) {
5355
5456 const NEUTRAL_LINE_BACKGROUND = "#52616f";
5557 const NEUTRAL_LINE_TEXT = "#ffffff";
58+const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
59+const ICON_MOVE_UP = "M6 14l6-6 6 6";
60+const ICON_MOVE_DOWN = "M6 10l6 6 6-6";
61+const ICON_REMOVE = "M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13M10 11v6M14 11v6";
5662
5763 function fallbackLineBadge(service) {
58- const namespace = "http://www.w3.org/2000/svg";
64+ const namespace = SVG_NAMESPACE;
5965 const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12));
6066 const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND;
6167 const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT;
@@ -113,16 +119,20 @@ function applyCopy() {
113119 document.title = copy.pageTitle;
114120 document.documentElement.lang = opening.locale;
115121 for (const [key, element] of [
116- ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],
122+ ["pageHeading", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],
117123 ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link],
124+ ["keyDocsLink", elements.key_docs_link], ["keyRequirement", elements.key_required],
125+ ["keyRequiredHint", elements.key_required_hint],
118126 ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title],
119127 ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title],
128+ ["favoriteAddCaption", elements.add_caption],
120129 ["searchLabel", elements.search_label], ["searchHint", elements.search_hint],
121130 ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title],
122131 ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add],
123132 ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back],
124133 ["aboutTitle", elements.about_title],
125134 ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint],
135+ ["syncCaption", elements.sync_caption],
126136 ["forceSyncLabel", elements.force_sync_label],
127137 ]) setText(element, copy[key]);
128138 elements.key_input.placeholder = copy.keyPlaceholder;
@@ -134,6 +144,8 @@ function applyCopy() {
134144
135145 function renderKey() {
136146 setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
147+ elements.key_required.hidden = state.hasKey;
148+ elements.key_required_hint.hidden = state.hasKey;
137149 const removing = state.keyDraft.removeRequested;
138150 const replacing = state.keyDraft.value.length > 0;
139151 elements.key_pending.hidden = !removing && !replacing;
@@ -154,12 +166,69 @@ function actionButton(label, disabled, action, className = "quiet") {
154166 return button;
155167 }
156168
169+function iconButton(label, disabled, pathDefinition, action, extraClass = "") {
170+ const button = document.createElement("button");
171+ button.type = "button";
172+ button.className = `icon-button quiet${extraClass.length > 0 ? ` ${extraClass}` : ""}`;
173+ button.disabled = disabled;
174+ button.setAttribute("aria-label", label);
175+ const icon = document.createElementNS(SVG_NAMESPACE, "svg");
176+ icon.setAttribute("viewBox", "0 0 24 24");
177+ icon.setAttribute("aria-hidden", "true");
178+ icon.setAttribute("focusable", "false");
179+ const glyph = document.createElementNS(SVG_NAMESPACE, "path");
180+ glyph.setAttribute("d", pathDefinition);
181+ glyph.setAttribute("fill", "none");
182+ glyph.setAttribute("stroke", "currentColor");
183+ glyph.setAttribute("stroke-width", "2");
184+ glyph.setAttribute("stroke-linecap", "round");
185+ glyph.setAttribute("stroke-linejoin", "round");
186+ icon.append(glyph);
187+ button.append(icon);
188+ button.addEventListener("click", action);
189+ return button;
190+}
191+
192+// A collapsed section must never swallow a validation error: opening the
193+// section before writing the message or moving focus keeps the failure
194+// visible instead of targeting a display:none element.
195+function revealSection(section) {
196+ if (section instanceof HTMLDetailsElement) section.open = true;
197+}
198+
199+// Keyboard move/remove rebuilds the whole list; the activation request lets
200+// renderFavorites put focus back on the affected favorite's same control
201+// instead of dropping it to <body>.
202+let pendingFavoriteFocus = null;
203+
204+function favoriteFocusTarget(request) {
205+ if (state.favorites.length === 0) {
206+ // The natural next step after the last removal is searching for a new
207+ // favorite; the Add section may be collapsed, so reveal it first.
208+ revealSection(elements.add_section);
209+ return elements.place_search;
210+ }
211+ let index = state.favorites.findIndex((favorite) => favorite.id === request.id);
212+ if (index < 0) index = Math.min(request.index, state.favorites.length - 1);
213+ if (index < 0) return null;
214+ const row = elements.favorites_list.children[index];
215+ if (row === undefined) return null;
216+ const control = row.querySelector(`[data-favorite-action="${request.action}"]`);
217+ if (control !== null && !control.disabled) return control;
218+ return row.querySelector("input");
219+}
220+
157221 function renderFavorites() {
158222 elements.favorites_list.replaceChildren();
159223 elements.favorites_empty.hidden = state.favorites.length > 0;
160- elements.add_section.hidden = state.favorites.length >= LIMITS.favorites;
161- if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit);
162- else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, "");
224+ const atLimit = state.favorites.length >= LIMITS.favorites;
225+ elements.add_section.hidden = atLimit;
226+ elements.favorites_status.hidden = !atLimit;
227+ if (atLimit) setText(elements.favorites_status, copy.favoriteLimit);
228+ const used = state.favorites.length;
229+ const available = LIMITS.favorites - used;
230+ setText(elements.favorites_count,
231+ `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`);
163232
164233 state.favorites.forEach((favorite, index) => {
165234 const item = document.createElement("li");
@@ -168,17 +237,25 @@ function renderFavorites() {
168237 renderLineBadge(route, favorite);
169238 const labels = document.createElement("div");
170239 labels.className = "favorite-labels";
240+ const journey = document.createElement("div");
241+ journey.className = "route-journey";
171242 const stop = document.createElement("strong");
243+ stop.className = "route-stop";
172244 stop.textContent = favorite.stopLabel;
173245 const destination = document.createElement("span");
246+ destination.className = "route-destination";
174247 destination.textContent = favorite.destinationLabel;
175- labels.append(stop, destination);
248+ journey.append(stop, destination);
249+ labels.append(journey);
176250 if (!Object.hasOwn(favorite, "routing")) {
177251 const unresolved = document.createElement("span");
178252 unresolved.className = "unresolved";
179253 unresolved.textContent = copy.favoriteUnresolved;
180254 labels.append(unresolved);
181255 }
256+ const routeRow = document.createElement("div");
257+ routeRow.className = "favorite-route";
258+ routeRow.append(route, labels);
182259
183260 const renameLabel = document.createElement("label");
184261 renameLabel.className = "visually-hidden";
@@ -187,6 +264,7 @@ function renderFavorites() {
187264 renameLabel.textContent = copy.favoriteRename;
188265 const rename = document.createElement("input");
189266 rename.id = renameId;
267+ rename.className = "favorite-name";
190268 rename.type = "text";
191269 rename.autocomplete = "off";
192270 rename.maxLength = LIMITS.labelUtf8Bytes;
@@ -194,23 +272,58 @@ function renderFavorites() {
194272 rename.placeholder = copy.favoriteNamePlaceholder;
195273 rename.addEventListener("change", () => {
196274 if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) {
197- dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value });
198- } else {
199- rename.value = favorite.displayName ?? "";
275+ // A rename changes no row besides the one being edited, so commit
276+ // into state without the rebuild: rebuilding here would drop
277+ // keyboard focus to body after the change and replace the icon
278+ // button under a just-pressed pointer.
279+ state = reduceConfigState(state, { type: "favorite-rename", id: favorite.id, displayName: rename.value });
200280 }
281+ // Reconcile the field with committed state rather than the
282+ // render-time capture: valid edits trim (and renames make the capture
283+ // stale), while oversized or control-bearing values are rejected by
284+ // the reducer and must revert to what is actually stored.
285+ const current = state.favorites.find((entry) => entry.id === favorite.id);
286+ rename.value = current?.displayName ?? "";
201287 });
202288
203-
289+ // Compact editing strip: reorder and remove sit beside the name field as
290+ // icon buttons; the localized action names double as accessible names.
291+ // Each control is tagged so focus can follow the affected row after the
292+ // rebuild that a move or removal triggers.
204293 const actions = document.createElement("div");
205294 actions.className = "favorite-actions";
206- actions.append(
207- actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })),
208- actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })),
209- actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"),
210- );
211- item.append(route, labels, renameLabel, rename, actions);
295+ const moveUpControl = iconButton(copy.favoriteMoveUp, index === 0, ICON_MOVE_UP,
296+ () => {
297+ pendingFavoriteFocus = { id: favorite.id, index, action: "move-up" };
298+ dispatch({ type: "favorite-move", id: favorite.id, delta: -1 });
299+ });
300+ const moveDownControl = iconButton(copy.favoriteMoveDown, index === state.favorites.length - 1, ICON_MOVE_DOWN,
301+ () => {
302+ pendingFavoriteFocus = { id: favorite.id, index, action: "move-down" };
303+ dispatch({ type: "favorite-move", id: favorite.id, delta: 1 });
304+ });
305+ const removeControl = iconButton(copy.favoriteRemove, false, ICON_REMOVE,
306+ () => {
307+ pendingFavoriteFocus = { id: favorite.id, index, action: "remove" };
308+ dispatch({ type: "favorite-remove", id: favorite.id });
309+ }, "danger");
310+ moveUpControl.dataset.favoriteAction = "move-up";
311+ moveDownControl.dataset.favoriteAction = "move-down";
312+ removeControl.dataset.favoriteAction = "remove";
313+ actions.append(moveUpControl, moveDownControl, removeControl);
314+ const edit = document.createElement("div");
315+ edit.className = "favorite-edit";
316+ edit.append(renameLabel, rename, actions);
317+ item.append(routeRow, edit);
212318 elements.favorites_list.append(item);
213319 });
320+
321+ if (pendingFavoriteFocus !== null) {
322+ const request = pendingFavoriteFocus;
323+ pendingFavoriteFocus = null;
324+ const target = favoriteFocusTarget(request);
325+ if (target !== null) target.focus();
326+ }
214327 }
215328
216329 function clearServiceSelection() {
@@ -228,10 +341,27 @@ function renderPlaces() {
228341 elements.place_results.replaceChildren();
229342 for (const place of places) {
230343 const item = document.createElement("li");
231- const label = place.localityLabel
232- ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}`
233- : `${place.stopLabel} · ${place.mode}`;
234- item.append(actionButton(label, false, () => selectPlace(place), "choice"));
344+ const button = actionButton("", false, () => selectPlace(place), "choice");
345+ const labels = document.createElement("span");
346+ labels.className = "choice-labels";
347+ const stop = document.createElement("strong");
348+ stop.textContent = place.stopLabel;
349+ const context = document.createElement("span");
350+ context.className = "choice-context";
351+ context.textContent = place.localityLabel ? `${place.localityLabel} · ${place.mode}` : place.mode;
352+ const lines = document.createElement("span");
353+ lines.className = "choice-lines";
354+ for (const line of place.lines) {
355+ // Search rows reuse the exact favorite/preview badge renderers: the
356+ // enclosing place mode selects the official pictogram, and lines
357+ // without one (BUS and unlisted labels) fall back to official colors.
358+ const badge = document.createElement("span");
359+ renderLineBadge(badge, { ...line, lineMode: place.mode });
360+ lines.append(badge);
361+ }
362+ labels.append(stop, context, lines);
363+ button.append(labels);
364+ item.append(button);
235365 elements.place_results.append(item);
236366 }
237367 }
@@ -247,6 +377,7 @@ async function selectPlace(place) {
247377 if (controller.signal.aborted) return;
248378 serviceController = null;
249379 if (services.length === 0) {
380+ revealSection(elements.add_section);
250381 setText(elements.catalog_status, copy.servicesEmpty);
251382 return;
252383 }
@@ -262,11 +393,13 @@ async function selectPlace(place) {
262393 elements.service_select.append(option);
263394 });
264395 elements.service_step.hidden = false;
396+ revealSection(elements.add_section);
265397 setText(elements.catalog_status, "");
266398 elements.service_select.focus();
267399 } catch (error) {
268400 if (controller.signal.aborted) return;
269401 serviceController = null;
402+ revealSection(elements.add_section);
270403 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE"
271404 ? copy.invalidService
272405 : copy.backendUnavailable);
@@ -360,9 +493,15 @@ elements.place_search.addEventListener("input", async () => {
360493 if (generation !== searchGeneration) return;
361494 places = result;
362495 renderPlaces();
363- setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : "");
496+ if (places.length === 0) {
497+ revealSection(elements.add_section);
498+ setText(elements.catalog_status, copy.searchNoResults);
499+ } else {
500+ setText(elements.catalog_status, "");
501+ }
364502 } catch (error) {
365503 if (error?.name === "AbortError" || generation !== searchGeneration) return;
504+ revealSection(elements.add_section);
366505 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY"
367506 ? copy.searchHint
368507 : copy.backendUnavailable);
@@ -423,6 +562,7 @@ elements.force_sync.addEventListener("change", () => {
423562 elements.save.addEventListener("click", () => {
424563 const keyError = apiKeyError(state.keyDraft.value);
425564 if (keyError !== null) {
565+ revealSection(elements.prim_section);
426566 setText(elements.key_error, copy[keyError]);
427567 elements.key_error.hidden = false;
428568 elements.key_input.focus();
@@ -430,11 +570,20 @@ elements.save.addEventListener("click", () => {
430570 }
431571 const outcome = planConfigResult(state);
432572 if (!outcome.ok) {
433- setText(elements.catalog_status, copy[outcome.error]);
573+ // At the favorite limit the add section carries the hidden attribute,
574+ // so save failures surface in the favorites list section instead: it is
575+ // visible in exactly the states where these failures occur.
576+ revealSection(elements.favorites_section);
577+ elements.favorites_status.hidden = false;
578+ setText(elements.favorites_status, copy[outcome.error]);
579+ elements.favorites_status.scrollIntoView({ block: "nearest" });
434580 return;
435581 }
436582 if (!closePayloadFits(outcome.payload)) {
437- setText(elements.catalog_status, copy.saveTooLarge);
583+ revealSection(elements.favorites_section);
584+ elements.favorites_status.hidden = false;
585+ setText(elements.favorites_status, copy.saveTooLarge);
586+ elements.favorites_status.scrollIntoView({ block: "nearest" });
438587 return;
439588 }
440589 const closeUrl = closeSession.close(outcome.payload);
@@ -443,6 +592,56 @@ elements.save.addEventListener("click", () => {
443592 window.location.assign(closeUrl);
444593 });
445594
595+// Section open/closed state is the only local persistence on this page:
596+// four booleans under one namespaced key, keyed by the stable section IDs.
597+// The key draft, favorites, and catalog data never reach storage, and
598+// unavailable or corrupt storage (private mode, blocked cookies, cleared
599+// origins) leaves the markup defaults in place and the page functional.
600+const SECTION_STORAGE_KEY = "lapin-fute:config-sections:v1";
601+const sectionElements = [
602+ elements.prim_section,
603+ elements.favorites_section,
604+ elements.add_section,
605+ elements.sync_section,
606+];
607+
608+function readStoredSectionStates() {
609+ try {
610+ const raw = window.localStorage.getItem(SECTION_STORAGE_KEY);
611+ if (raw === null) return null;
612+ const saved = JSON.parse(raw);
613+ if (saved === null || typeof saved !== "object") return null;
614+ return sectionElements
615+ .filter((section) => typeof saved[section.id] === "boolean")
616+ .map((section) => [section, saved[section.id]]);
617+ } catch {
618+ return null;
619+ }
620+}
621+
622+function persistSectionStates() {
623+ try {
624+ const states = {};
625+ for (const section of sectionElements) states[section.id] = section.open;
626+ window.localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(states));
627+ } catch {
628+ // Persistence is a convenience; toggling must keep working without it.
629+ }
630+}
631+
632+// Restore all sections before registering listeners so queued toggle events
633+// persist the complete restored state.
634+const storedSectionStates = readStoredSectionStates();
635+if (storedSectionStates !== null) {
636+ for (const [section, open] of storedSectionStates) section.open = open;
637+}
638+for (const section of sectionElements) {
639+ section.addEventListener("toggle", persistSectionStates);
640+}
641+// The details toggle event is queued with rendering steps and can be lost
642+// to an immediate close, so navigating away flushes the live DOM state.
643+window.addEventListener("pagehide", persistSectionStates);
644+
446645 applyCopy();
447646 renderKey();
448647 renderFavorites();
@@ -22,14 +22,16 @@ function byId(id) {
22 }22 }
23 23
24 const elements = Object.fromEntries([24 const elements = Object.fromEntries([
25- "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label",25+ "page-title", "intro", "prim-section", "key-title", "key-status", "key-required", "key-explanation",
26- "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title",26+ "key-link", "key-docs-link", "key-required-hint", "key-label", "key-input", "key-toggle", "key-error",
27- "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint",27+ "key-pending", "key-remove", "favorites-section", "favorites-title", "favorites-count", "favorites-status", "favorites-empty",
28- "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview",28+ "favorites-list", "add-title", "search-label", "place-search", "search-hint", "catalog-status",
29- "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures",29+ "place-results", "service-step", "services-label", "service-select", "preview", "preview-title",
30- "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view",30+ "preview-line", "preview-stop", "preview-destination", "preview-departures", "favorite-name-label",
31- "sync-title", "sync-hint", "force-sync-label", "force-sync",31+ "favorite-name", "favorite-add", "add-section", "save", "config-view", "sync-section", "sync-title", "sync-hint",
32- "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr",32+ "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back",
33+ "about-title", "about-en", "about-fr",
34+ "add-caption", "sync-caption",
33 ].map((id) => [id.replaceAll("-", "_"), byId(id)]));35 ].map((id) => [id.replaceAll("-", "_"), byId(id)]));
34 36
35 const opening = parseConfigFragment(window.location.hash);37 const opening = parseConfigFragment(window.location.hash);
@@ -53,9 +55,13 @@ function setText(element, value) {
53 55
54 const NEUTRAL_LINE_BACKGROUND = "#52616f";56 const NEUTRAL_LINE_BACKGROUND = "#52616f";
55 const NEUTRAL_LINE_TEXT = "#ffffff";57 const NEUTRAL_LINE_TEXT = "#ffffff";
58+const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
59+const ICON_MOVE_UP = "M6 14l6-6 6 6";
60+const ICON_MOVE_DOWN = "M6 10l6 6 6-6";
61+const ICON_REMOVE = "M4 7h16M9 7V4h6v3M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13M10 11v6M14 11v6";
56 62
57 function fallbackLineBadge(service) {63 function fallbackLineBadge(service) {
58- const namespace = "http://www.w3.org/2000/svg";64+ const namespace = SVG_NAMESPACE;
59 const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12));65 const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12));
60 const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND;66 const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND;
61 const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT;67 const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT;
@@ -113,16 +119,20 @@ function applyCopy() {
113 document.title = copy.pageTitle;119 document.title = copy.pageTitle;
114 document.documentElement.lang = opening.locale;120 document.documentElement.lang = opening.locale;
115 for (const [key, element] of [121 for (const [key, element] of [
116- ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],122+ ["pageHeading", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],
117 ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link],123 ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link],
124+ ["keyDocsLink", elements.key_docs_link], ["keyRequirement", elements.key_required],
125+ ["keyRequiredHint", elements.key_required_hint],
118 ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title],126 ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title],
119 ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title],127 ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title],
128+ ["favoriteAddCaption", elements.add_caption],
120 ["searchLabel", elements.search_label], ["searchHint", elements.search_hint],129 ["searchLabel", elements.search_label], ["searchHint", elements.search_hint],
121 ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title],130 ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title],
122 ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add],131 ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add],
123 ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back],132 ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back],
124 ["aboutTitle", elements.about_title],133 ["aboutTitle", elements.about_title],
125 ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint],134 ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint],
135+ ["syncCaption", elements.sync_caption],
126 ["forceSyncLabel", elements.force_sync_label],136 ["forceSyncLabel", elements.force_sync_label],
127 ]) setText(element, copy[key]);137 ]) setText(element, copy[key]);
128 elements.key_input.placeholder = copy.keyPlaceholder;138 elements.key_input.placeholder = copy.keyPlaceholder;
@@ -134,6 +144,8 @@ function applyCopy() {
134 144
135 function renderKey() {145 function renderKey() {
136 setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);146 setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
147+ elements.key_required.hidden = state.hasKey;
148+ elements.key_required_hint.hidden = state.hasKey;
137 const removing = state.keyDraft.removeRequested;149 const removing = state.keyDraft.removeRequested;
138 const replacing = state.keyDraft.value.length > 0;150 const replacing = state.keyDraft.value.length > 0;
139 elements.key_pending.hidden = !removing && !replacing;151 elements.key_pending.hidden = !removing && !replacing;
@@ -154,12 +166,69 @@ function actionButton(label, disabled, action, className = "quiet") {
154 return button;166 return button;
155 }167 }
156 168
169+function iconButton(label, disabled, pathDefinition, action, extraClass = "") {
170+ const button = document.createElement("button");
171+ button.type = "button";
172+ button.className = `icon-button quiet${extraClass.length > 0 ? ` ${extraClass}` : ""}`;
173+ button.disabled = disabled;
174+ button.setAttribute("aria-label", label);
175+ const icon = document.createElementNS(SVG_NAMESPACE, "svg");
176+ icon.setAttribute("viewBox", "0 0 24 24");
177+ icon.setAttribute("aria-hidden", "true");
178+ icon.setAttribute("focusable", "false");
179+ const glyph = document.createElementNS(SVG_NAMESPACE, "path");
180+ glyph.setAttribute("d", pathDefinition);
181+ glyph.setAttribute("fill", "none");
182+ glyph.setAttribute("stroke", "currentColor");
183+ glyph.setAttribute("stroke-width", "2");
184+ glyph.setAttribute("stroke-linecap", "round");
185+ glyph.setAttribute("stroke-linejoin", "round");
186+ icon.append(glyph);
187+ button.append(icon);
188+ button.addEventListener("click", action);
189+ return button;
190+}
191+
192+// A collapsed section must never swallow a validation error: opening the
193+// section before writing the message or moving focus keeps the failure
194+// visible instead of targeting a display:none element.
195+function revealSection(section) {
196+ if (section instanceof HTMLDetailsElement) section.open = true;
197+}
198+
199+// Keyboard move/remove rebuilds the whole list; the activation request lets
200+// renderFavorites put focus back on the affected favorite's same control
201+// instead of dropping it to <body>.
202+let pendingFavoriteFocus = null;
203+
204+function favoriteFocusTarget(request) {
205+ if (state.favorites.length === 0) {
206+ // The natural next step after the last removal is searching for a new
207+ // favorite; the Add section may be collapsed, so reveal it first.
208+ revealSection(elements.add_section);
209+ return elements.place_search;
210+ }
211+ let index = state.favorites.findIndex((favorite) => favorite.id === request.id);
212+ if (index < 0) index = Math.min(request.index, state.favorites.length - 1);
213+ if (index < 0) return null;
214+ const row = elements.favorites_list.children[index];
215+ if (row === undefined) return null;
216+ const control = row.querySelector(`[data-favorite-action="${request.action}"]`);
217+ if (control !== null && !control.disabled) return control;
218+ return row.querySelector("input");
219+}
220+
157 function renderFavorites() {221 function renderFavorites() {
158 elements.favorites_list.replaceChildren();222 elements.favorites_list.replaceChildren();
159 elements.favorites_empty.hidden = state.favorites.length > 0;223 elements.favorites_empty.hidden = state.favorites.length > 0;
160- elements.add_section.hidden = state.favorites.length >= LIMITS.favorites;224+ const atLimit = state.favorites.length >= LIMITS.favorites;
161- if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit);225+ elements.add_section.hidden = atLimit;
162- else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, "");226+ elements.favorites_status.hidden = !atLimit;
227+ if (atLimit) setText(elements.favorites_status, copy.favoriteLimit);
228+ const used = state.favorites.length;
229+ const available = LIMITS.favorites - used;
230+ setText(elements.favorites_count,
231+ `${used} ${used === 1 ? copy.favoriteUsed : copy.favoritesUsed} · ${available} ${available === 1 ? copy.favoriteAvailable : copy.favoritesAvailable}`);
163 232
164 state.favorites.forEach((favorite, index) => {233 state.favorites.forEach((favorite, index) => {
165 const item = document.createElement("li");234 const item = document.createElement("li");
@@ -168,17 +237,25 @@ function renderFavorites() {
168 renderLineBadge(route, favorite);237 renderLineBadge(route, favorite);
169 const labels = document.createElement("div");238 const labels = document.createElement("div");
170 labels.className = "favorite-labels";239 labels.className = "favorite-labels";
240+ const journey = document.createElement("div");
241+ journey.className = "route-journey";
171 const stop = document.createElement("strong");242 const stop = document.createElement("strong");
243+ stop.className = "route-stop";
172 stop.textContent = favorite.stopLabel;244 stop.textContent = favorite.stopLabel;
173 const destination = document.createElement("span");245 const destination = document.createElement("span");
246+ destination.className = "route-destination";
174 destination.textContent = favorite.destinationLabel;247 destination.textContent = favorite.destinationLabel;
175- labels.append(stop, destination);248+ journey.append(stop, destination);
249+ labels.append(journey);
176 if (!Object.hasOwn(favorite, "routing")) {250 if (!Object.hasOwn(favorite, "routing")) {
177 const unresolved = document.createElement("span");251 const unresolved = document.createElement("span");
178 unresolved.className = "unresolved";252 unresolved.className = "unresolved";
179 unresolved.textContent = copy.favoriteUnresolved;253 unresolved.textContent = copy.favoriteUnresolved;
180 labels.append(unresolved);254 labels.append(unresolved);
181 }255 }
256+ const routeRow = document.createElement("div");
257+ routeRow.className = "favorite-route";
258+ routeRow.append(route, labels);
182 259
183 const renameLabel = document.createElement("label");260 const renameLabel = document.createElement("label");
184 renameLabel.className = "visually-hidden";261 renameLabel.className = "visually-hidden";
@@ -187,6 +264,7 @@ function renderFavorites() {
187 renameLabel.textContent = copy.favoriteRename;264 renameLabel.textContent = copy.favoriteRename;
188 const rename = document.createElement("input");265 const rename = document.createElement("input");
189 rename.id = renameId;266 rename.id = renameId;
267+ rename.className = "favorite-name";
190 rename.type = "text";268 rename.type = "text";
191 rename.autocomplete = "off";269 rename.autocomplete = "off";
192 rename.maxLength = LIMITS.labelUtf8Bytes;270 rename.maxLength = LIMITS.labelUtf8Bytes;
@@ -194,23 +272,58 @@ function renderFavorites() {
194 rename.placeholder = copy.favoriteNamePlaceholder;272 rename.placeholder = copy.favoriteNamePlaceholder;
195 rename.addEventListener("change", () => {273 rename.addEventListener("change", () => {
196 if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) {274 if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) {
197- dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value });275+ // A rename changes no row besides the one being edited, so commit
198- } else {276+ // into state without the rebuild: rebuilding here would drop
199- rename.value = favorite.displayName ?? "";277+ // keyboard focus to body after the change and replace the icon
278+ // button under a just-pressed pointer.
279+ state = reduceConfigState(state, { type: "favorite-rename", id: favorite.id, displayName: rename.value });
200 }280 }
281+ // Reconcile the field with committed state rather than the
282+ // render-time capture: valid edits trim (and renames make the capture
283+ // stale), while oversized or control-bearing values are rejected by
284+ // the reducer and must revert to what is actually stored.
285+ const current = state.favorites.find((entry) => entry.id === favorite.id);
286+ rename.value = current?.displayName ?? "";
201 });287 });
202 288
203-289+ // Compact editing strip: reorder and remove sit beside the name field as
290+ // icon buttons; the localized action names double as accessible names.
291+ // Each control is tagged so focus can follow the affected row after the
292+ // rebuild that a move or removal triggers.
204 const actions = document.createElement("div");293 const actions = document.createElement("div");
205 actions.className = "favorite-actions";294 actions.className = "favorite-actions";
206- actions.append(295+ const moveUpControl = iconButton(copy.favoriteMoveUp, index === 0, ICON_MOVE_UP,
207- actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })),296+ () => {
208- actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })),297+ pendingFavoriteFocus = { id: favorite.id, index, action: "move-up" };
209- actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"),298+ dispatch({ type: "favorite-move", id: favorite.id, delta: -1 });
210- );299+ });
211- item.append(route, labels, renameLabel, rename, actions);300+ const moveDownControl = iconButton(copy.favoriteMoveDown, index === state.favorites.length - 1, ICON_MOVE_DOWN,
301+ () => {
302+ pendingFavoriteFocus = { id: favorite.id, index, action: "move-down" };
303+ dispatch({ type: "favorite-move", id: favorite.id, delta: 1 });
304+ });
305+ const removeControl = iconButton(copy.favoriteRemove, false, ICON_REMOVE,
306+ () => {
307+ pendingFavoriteFocus = { id: favorite.id, index, action: "remove" };
308+ dispatch({ type: "favorite-remove", id: favorite.id });
309+ }, "danger");
310+ moveUpControl.dataset.favoriteAction = "move-up";
311+ moveDownControl.dataset.favoriteAction = "move-down";
312+ removeControl.dataset.favoriteAction = "remove";
313+ actions.append(moveUpControl, moveDownControl, removeControl);
314+ const edit = document.createElement("div");
315+ edit.className = "favorite-edit";
316+ edit.append(renameLabel, rename, actions);
317+ item.append(routeRow, edit);
212 elements.favorites_list.append(item);318 elements.favorites_list.append(item);
213 });319 });
320+
321+ if (pendingFavoriteFocus !== null) {
322+ const request = pendingFavoriteFocus;
323+ pendingFavoriteFocus = null;
324+ const target = favoriteFocusTarget(request);
325+ if (target !== null) target.focus();
326+ }
214 }327 }
215 328
216 function clearServiceSelection() {329 function clearServiceSelection() {
@@ -228,10 +341,27 @@ function renderPlaces() {
228 elements.place_results.replaceChildren();341 elements.place_results.replaceChildren();
229 for (const place of places) {342 for (const place of places) {
230 const item = document.createElement("li");343 const item = document.createElement("li");
231- const label = place.localityLabel344+ const button = actionButton("", false, () => selectPlace(place), "choice");
232- ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}`345+ const labels = document.createElement("span");
233- : `${place.stopLabel} · ${place.mode}`;346+ labels.className = "choice-labels";
234- item.append(actionButton(label, false, () => selectPlace(place), "choice"));347+ const stop = document.createElement("strong");
348+ stop.textContent = place.stopLabel;
349+ const context = document.createElement("span");
350+ context.className = "choice-context";
351+ context.textContent = place.localityLabel ? `${place.localityLabel} · ${place.mode}` : place.mode;
352+ const lines = document.createElement("span");
353+ lines.className = "choice-lines";
354+ for (const line of place.lines) {
355+ // Search rows reuse the exact favorite/preview badge renderers: the
356+ // enclosing place mode selects the official pictogram, and lines
357+ // without one (BUS and unlisted labels) fall back to official colors.
358+ const badge = document.createElement("span");
359+ renderLineBadge(badge, { ...line, lineMode: place.mode });
360+ lines.append(badge);
361+ }
362+ labels.append(stop, context, lines);
363+ button.append(labels);
364+ item.append(button);
235 elements.place_results.append(item);365 elements.place_results.append(item);
236 }366 }
237 }367 }
@@ -247,6 +377,7 @@ async function selectPlace(place) {
247 if (controller.signal.aborted) return;377 if (controller.signal.aborted) return;
248 serviceController = null;378 serviceController = null;
249 if (services.length === 0) {379 if (services.length === 0) {
380+ revealSection(elements.add_section);
250 setText(elements.catalog_status, copy.servicesEmpty);381 setText(elements.catalog_status, copy.servicesEmpty);
251 return;382 return;
252 }383 }
@@ -262,11 +393,13 @@ async function selectPlace(place) {
262 elements.service_select.append(option);393 elements.service_select.append(option);
263 });394 });
264 elements.service_step.hidden = false;395 elements.service_step.hidden = false;
396+ revealSection(elements.add_section);
265 setText(elements.catalog_status, "");397 setText(elements.catalog_status, "");
266 elements.service_select.focus();398 elements.service_select.focus();
267 } catch (error) {399 } catch (error) {
268 if (controller.signal.aborted) return;400 if (controller.signal.aborted) return;
269 serviceController = null;401 serviceController = null;
402+ revealSection(elements.add_section);
270 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE"403 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE"
271 ? copy.invalidService404 ? copy.invalidService
272 : copy.backendUnavailable);405 : copy.backendUnavailable);
@@ -360,9 +493,15 @@ elements.place_search.addEventListener("input", async () => {
360 if (generation !== searchGeneration) return;493 if (generation !== searchGeneration) return;
361 places = result;494 places = result;
362 renderPlaces();495 renderPlaces();
363- setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : "");496+ if (places.length === 0) {
497+ revealSection(elements.add_section);
498+ setText(elements.catalog_status, copy.searchNoResults);
499+ } else {
500+ setText(elements.catalog_status, "");
501+ }
364 } catch (error) {502 } catch (error) {
365 if (error?.name === "AbortError" || generation !== searchGeneration) return;503 if (error?.name === "AbortError" || generation !== searchGeneration) return;
504+ revealSection(elements.add_section);
366 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY"505 setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY"
367 ? copy.searchHint506 ? copy.searchHint
368 : copy.backendUnavailable);507 : copy.backendUnavailable);
@@ -423,6 +562,7 @@ elements.force_sync.addEventListener("change", () => {
423 elements.save.addEventListener("click", () => {562 elements.save.addEventListener("click", () => {
424 const keyError = apiKeyError(state.keyDraft.value);563 const keyError = apiKeyError(state.keyDraft.value);
425 if (keyError !== null) {564 if (keyError !== null) {
565+ revealSection(elements.prim_section);
426 setText(elements.key_error, copy[keyError]);566 setText(elements.key_error, copy[keyError]);
427 elements.key_error.hidden = false;567 elements.key_error.hidden = false;
428 elements.key_input.focus();568 elements.key_input.focus();
@@ -430,11 +570,20 @@ elements.save.addEventListener("click", () => {
430 }570 }
431 const outcome = planConfigResult(state);571 const outcome = planConfigResult(state);
432 if (!outcome.ok) {572 if (!outcome.ok) {
433- setText(elements.catalog_status, copy[outcome.error]);573+ // At the favorite limit the add section carries the hidden attribute,
574+ // so save failures surface in the favorites list section instead: it is
575+ // visible in exactly the states where these failures occur.
576+ revealSection(elements.favorites_section);
577+ elements.favorites_status.hidden = false;
578+ setText(elements.favorites_status, copy[outcome.error]);
579+ elements.favorites_status.scrollIntoView({ block: "nearest" });
434 return;580 return;
435 }581 }
436 if (!closePayloadFits(outcome.payload)) {582 if (!closePayloadFits(outcome.payload)) {
437- setText(elements.catalog_status, copy.saveTooLarge);583+ revealSection(elements.favorites_section);
584+ elements.favorites_status.hidden = false;
585+ setText(elements.favorites_status, copy.saveTooLarge);
586+ elements.favorites_status.scrollIntoView({ block: "nearest" });
438 return;587 return;
439 }588 }
440 const closeUrl = closeSession.close(outcome.payload);589 const closeUrl = closeSession.close(outcome.payload);
@@ -443,6 +592,56 @@ elements.save.addEventListener("click", () => {
443 window.location.assign(closeUrl);592 window.location.assign(closeUrl);
444 });593 });
445 594
595+// Section open/closed state is the only local persistence on this page:
596+// four booleans under one namespaced key, keyed by the stable section IDs.
597+// The key draft, favorites, and catalog data never reach storage, and
598+// unavailable or corrupt storage (private mode, blocked cookies, cleared
599+// origins) leaves the markup defaults in place and the page functional.
600+const SECTION_STORAGE_KEY = "lapin-fute:config-sections:v1";
601+const sectionElements = [
602+ elements.prim_section,
603+ elements.favorites_section,
604+ elements.add_section,
605+ elements.sync_section,
606+];
607+
608+function readStoredSectionStates() {
609+ try {
610+ const raw = window.localStorage.getItem(SECTION_STORAGE_KEY);
611+ if (raw === null) return null;
612+ const saved = JSON.parse(raw);
613+ if (saved === null || typeof saved !== "object") return null;
614+ return sectionElements
615+ .filter((section) => typeof saved[section.id] === "boolean")
616+ .map((section) => [section, saved[section.id]]);
617+ } catch {
618+ return null;
619+ }
620+}
621+
622+function persistSectionStates() {
623+ try {
624+ const states = {};
625+ for (const section of sectionElements) states[section.id] = section.open;
626+ window.localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(states));
627+ } catch {
628+ // Persistence is a convenience; toggling must keep working without it.
629+ }
630+}
631+
632+// Restore all sections before registering listeners so queued toggle events
633+// persist the complete restored state.
634+const storedSectionStates = readStoredSectionStates();
635+if (storedSectionStates !== null) {
636+ for (const [section, open] of storedSectionStates) section.open = open;
637+}
638+for (const section of sectionElements) {
639+ section.addEventListener("toggle", persistSectionStates);
640+}
641+// The details toggle event is queued with rendering steps and can be lost
642+// to an immediate close, so navigating away flushes the live DOM state.
643+window.addEventListener("pagehide", persistSectionStates);
644+
446 applyCopy();645 applyCopy();
447 renderKey();646 renderKey();
448 renderFavorites();647 renderFavorites();
modified packages/config-page/styles/config-page.css +274 -207
@@ -1,251 +1,318 @@
1+/* Lapin Futé companion: a settings sheet and a small route board.
2+ * Pebble red and achromatic neutrals follow repebble.com; text, field borders,
3+ * and focus tones are adapted for contrast. Official line colors stay separate.
4+ */
15 :root {
26 color-scheme: light;
3- --navy: #102a43;
4- --blue: #1769aa;
5- --blue-soft: #eaf3fa;
6- --red: #c7353a;
7- --ink: #17212b;
8- --muted: #52616f;
9- --line: #cad6e0;
10- --paper: #f4f7f9;
11- --white: #ffffff;
12- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
7+ --page: #f5f5f5;
8+ --surface: #ffffff;
9+ --surface-soft: #f5f5f5;
10+ --text: #171717;
11+ --heading: #0a0a0a;
12+ --muted: #666666;
13+ --line: #e5e5e5;
14+ --input-line: #8a8a8a;
15+ --accent: #fa4a36;
16+ --accent-strong: #c73a28;
17+ --on-accent: #0a0a0a;
18+ --danger: #c90012;
19+ --danger-soft: #fff0f1;
20+ --focus: #c73a28;
21+ --font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
22+ --font-heading: "Avenir Next", Avenir, "Trebuchet MS", system-ui, sans-serif;
23+ --font-utility: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
1324 }
1425
15-* { box-sizing: border-box; }
26+@media (prefers-color-scheme: dark) {
27+ :root {
28+ color-scheme: dark;
29+ --page: #0a0a0a;
30+ --surface: #171717;
31+ --surface-soft: #262626;
32+ --text: #e5e5e5;
33+ --heading: #fafafa;
34+ --muted: #a1a1a1;
35+ --line: #333333;
36+ --input-line: #737373;
37+ --accent: #fa4a36;
38+ --accent-strong: #ff7867;
39+ --on-accent: #0a0a0a;
40+ --danger: #ff6568;
41+ --danger-soft: #34191b;
42+ --focus: #fa4a36;
43+ }
44+}
1645
46+* { box-sizing: border-box; }
47+html { scroll-padding-block: 1rem calc(7rem + env(safe-area-inset-bottom)); }
1748 html:not(.ready) body { visibility: hidden; }
18-
1949 body {
20- margin: 0;
50+ display: flex;
51+ flex-direction: column;
2152 min-width: 18rem;
22- background: var(--paper);
23- color: var(--ink);
53+ min-height: 100vh;
54+ min-height: 100svh;
55+ margin: 0;
56+ background: var(--page);
57+ color: var(--text);
58+ font-family: var(--font-body);
2459 font-size: 1rem;
25- line-height: 1.45;
60+ line-height: 1.5;
2661 }
27-
2862 button, input, select { font: inherit; }
29-button, a { -webkit-tap-highlight-color: transparent; }
63+button, a, summary { -webkit-tap-highlight-color: transparent; }
64+h1, h2, h3, p { margin: 0; }
65+h1, h2, h3 { color: var(--heading); font-family: var(--font-heading); }
66+a { color: var(--accent-strong); text-underline-offset: 0.2em; }
3067
31-.masthead {
68+/* The app bar is branding, not a hero. Settings gets its own page heading. */
69+.app-header {
3270 display: flex;
33- gap: 0.875rem;
3471 align-items: center;
35- padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem;
36- background: var(--navy);
37- color: var(--white);
38- border-bottom: 0.25rem solid var(--red);
72+ justify-content: space-between;
73+ gap: 1rem;
74+ padding: max(0.875rem, env(safe-area-inset-top)) max(1.25rem, calc((100% - 35.5rem) / 2)) 0.875rem;
75+ background: var(--surface);
3976 }
40-
41-.route-mark {
42- display: inline-grid;
43- place-items: center;
44- flex: 0 0 auto;
45- min-width: 2.5rem;
46- min-height: 2.5rem;
47- padding: 0.25rem 0.5rem;
48- border-radius: 0.35rem;
49- background: var(--blue);
50- color: var(--white);
77+.app-brand { display: flex; align-items: center; gap: 0.5rem; }
78+.app-brand > span {
79+ color: var(--heading);
80+ font-family: var(--font-heading);
81+ font-size: 1.25rem;
5182 font-weight: 800;
52- letter-spacing: -0.02em;
83+ letter-spacing: -0.045em;
5384 }
85+.brand-mark { width: 2rem; height: 2rem; }
86+main { flex: 1; width: min(100%, 38rem); margin: 0 auto; padding: 1.5rem 1rem; }
87+.page-heading { padding: 0 0.25rem 1.25rem; }
88+h1 { font-size: 1.875rem; font-weight: 800; letter-spacing: -0.045em; line-height: 1.15; }
89+.page-heading p { max-width: 30rem; margin-top: 0.5rem; color: var(--muted); font-size: 0.875rem; line-height: 1.55; }
5490
55-.line-badge {
56- display: inline-grid;
91+/* One sheet, four independent native disclosures. The caption and pictogram
92+ * identify the section even when its controls are folded away. */
93+.settings-list { background: var(--surface); border-radius: 1.25rem; }
94+.settings-section { border-bottom: 1px solid var(--line); }
95+.settings-section:last-child { border-bottom: 0; }
96+.settings-section > summary {
97+ display: grid;
98+ grid-template-columns: 2.25rem minmax(0, 1fr) 1.5rem;
99+ align-items: center;
100+ gap: 0.75rem;
101+ min-height: 5.25rem;
102+ padding: 1.125rem 1.25rem;
103+ cursor: pointer;
104+ list-style: none;
105+}
106+.settings-section > summary::-webkit-details-marker { display: none; }
107+.settings-section > summary::marker { content: ""; }
108+.settings-section:first-child > summary { border-radius: 1.25rem 1.25rem 0 0; }
109+.settings-section:last-child:not([open]) > summary { border-radius: 0 0 1.25rem 1.25rem; }
110+.section-icon {
111+ display: grid;
57112 place-items: center;
58- flex: 0 0 auto;
59- min-width: 2.5rem;
60- min-height: 2.5rem;
61- vertical-align: middle;
113+ width: 2.25rem;
114+ height: 2.25rem;
115+ border-radius: 0.75rem;
116+ background: var(--surface-soft);
117+ color: var(--accent-strong);
62118 }
63-
64-.line-badge img, .line-badge svg {
119+.section-icon svg { width: 1.35rem; height: 1.35rem; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
120+.summary-copy { min-width: 0; }
121+.summary-copy h2 { font-size: 1.0625rem; font-weight: 800; line-height: 1.3; letter-spacing: -0.025em; overflow-wrap: anywhere; }
122+.section-caption, .summary-hint, .favorite-capacity {
65123 display: block;
66- width: auto;
67- height: 2.5rem;
68- max-width: 8rem;
69-}
70-
71-h1, h2, p { margin-top: 0; }
72-h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; }
73-.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; }
74-
75-main {
76- width: min(100%, 38rem);
77- margin: 0 auto;
78- padding: 0.75rem;
79-}
80-
81-section {
82- margin-bottom: 0.75rem;
83- padding: 1rem;
84- background: var(--white);
85- border: 1px solid var(--line);
86- border-radius: 0.5rem;
124+ margin-top: 0.25rem;
125+ color: var(--muted);
126+ font-size: 0.75rem;
127+ line-height: 1.45;
128+ overflow-wrap: anywhere;
87129 }
88-
89-.section-heading {
90- display: flex;
91- align-items: baseline;
92- gap: 0.625rem;
93- margin-bottom: 0.75rem;
94- border-bottom: 1px solid var(--line);
95-}
96-
97-.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; }
98-h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; }
99-h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; }
100-
101-.field-label {
102- display: block;
103- margin: 0.8rem 0 0.35rem;
104- color: var(--navy);
105- font-size: 0.88rem;
106- font-weight: 700;
130+.summary-hint { color: var(--accent-strong); }
131+.favorite-capacity { font-variant-numeric: tabular-nums; }
132+.disclosure { display: grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--page); }
133+.disclosure::after {
134+ content: "";
135+ width: 0.4rem;
136+ height: 0.4rem;
137+ margin-top: -0.2rem;
138+ border-right: 1.5px solid var(--muted);
139+ border-bottom: 1.5px solid var(--muted);
140+ transform: rotate(45deg);
141+ transition: transform 0.16s ease;
107142 }
143+.settings-section[open] > summary { padding-bottom: 0.875rem; }
144+.settings-section[open] .section-icon { background: var(--accent); color: var(--on-accent); }
145+.settings-section[open] .disclosure::after { margin-top: 0.15rem; transform: rotate(225deg); }
146+.settings-section[open] .summary-hint, .settings-section[open] .section-caption { display: none; }
147+.section-body { padding: 0 1.25rem 1.25rem; }
108148
149+/* Filled fields keep the chrome quiet without disguising editable values. */
150+.field-label { display: block; margin: 1rem 0 0.5rem; color: var(--heading); font-size: 0.8125rem; font-weight: 700; }
151+.section-body > .field-label:first-child { margin-top: 0; }
109152 input, select {
110153 width: 100%;
111- min-height: 2.75rem;
112- padding: 0.65rem 0.75rem;
113- border: 1px solid #8fa3b5;
114- border-radius: 0.35rem;
115- background: var(--white);
116- color: var(--ink);
154+ min-width: 0;
155+ min-height: 3rem;
156+ padding: 0.625rem 0.75rem;
157+ border: 1px solid var(--input-line);
158+ border-radius: 0.625rem;
159+ background: var(--surface);
160+ color: var(--text);
161+ font-size: 1rem;
117162 }
118-
119-input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible {
120- outline: 0.2rem solid #ffb81c;
121- outline-offset: 0.15rem;
163+input::placeholder { color: var(--muted); opacity: 1; }
164+input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible, summary:focus-visible {
165+ outline: 3px solid var(--focus);
166+ outline-offset: 3px;
122167 }
123-
124-.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
125-.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; }
126-.status, .support { color: var(--muted); }
127-.pending { color: var(--blue); font-weight: 700; }
128-.error { color: #9b1c24; font-weight: 700; }
129-a { color: var(--blue); font-weight: 700; }
168+summary:focus-visible { outline-offset: -3px; }
169+.status, .support { color: var(--muted); font-size: 0.8125rem; line-height: 1.55; }
170+.status:empty { display: none; }
171+.pending, .error { margin-top: 0.75rem; font-size: 0.8125rem; font-weight: 600; line-height: 1.5; }
172+.pending { color: var(--accent-strong); }
173+.error { padding: 0.75rem; border-radius: 0.625rem; background: var(--danger-soft); color: var(--danger); }
174+#favorites-status { margin: 0 0 0.75rem; }
130175
131176 button {
177+ min-width: 0;
132178 min-height: 2.75rem;
133- padding: 0.55rem 0.8rem;
179+ padding: 0.625rem 0.875rem;
134180 border: 1px solid transparent;
135- border-radius: 0.35rem;
181+ border-radius: 0.625rem;
136182 cursor: pointer;
137- font-weight: 750;
183+ font-weight: 700;
184+ line-height: 1.35;
185+ overflow-wrap: anywhere;
138186 }
139-button:disabled { cursor: default; opacity: 0.45; }
140-.quiet { border-color: var(--line); background: var(--white); color: var(--navy); }
141-.danger { color: #9b1c24; }
142-#key-remove { margin-top: 0.25rem; }
187+button:disabled { cursor: default; opacity: 0.35; }
188+.quiet { background: var(--surface-soft); color: var(--heading); }
189+.danger { color: var(--danger); }
190+.primary, .secondary { background: var(--accent); color: var(--on-accent); }
143191
144-.favorites, .choice-list {
145- display: grid;
146- gap: 0.55rem;
147- margin: 0;
148- padding: 0;
149- list-style: none;
150-}
192+/* Access guidance leads to creation and entry; storage detail is a footnote. */
193+.requirement { font-size: 0.875rem; line-height: 1.6; }
194+.key-links { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.75rem; margin-top: 0.75rem; }
195+.link-action, .link-secondary { display: inline-flex; align-items: center; min-height: 2.75rem; font-size: 0.8125rem; font-weight: 700; }
196+.link-action { gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 0.625rem; background: var(--surface-soft); color: var(--accent-strong); text-decoration: none; }
197+.link-action::after { content: "↗"; font-size: 1.1rem; line-height: 1; }
198+.link-secondary { color: var(--muted); }
199+.key-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.25rem; border: 1px solid var(--input-line); border-radius: 0.75rem; background: var(--page); }
200+.key-row input { min-height: 2.75rem; padding-inline: 0.5rem; border-color: transparent; background: transparent; }
201+#key-toggle { background: var(--surface); font-size: 0.8125rem; }
202+#key-remove { margin-top: 0.75rem; background: var(--danger-soft); font-size: 0.8125rem; }
203+.key-note { margin-top: 1rem; padding: 0.75rem 0.875rem; border-radius: 0.75rem; background: var(--page); }
204+.key-note .status { color: var(--heading); font-weight: 600; }
205+.key-note .support { margin-top: 0.25rem; font-size: 0.75rem; }
151206
152-.favorite {
153- display: grid;
154- grid-template-columns: auto 1fr;
155- gap: 0.65rem;
156- padding: 0.75rem 0;
157- border-bottom: 1px solid var(--line);
158-}
159-.favorite:last-child { border-bottom: 0; }
160-.favorite-labels { display: grid; align-content: center; min-width: 0; }
161-.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; }
162-.favorite-labels span { color: var(--muted); font-size: 0.85rem; }
163-.favorite-labels .unresolved { color: #9b1c24; font-size: 0.78rem; font-weight: 700; }
164-.favorite > input { grid-column: 1 / -1; }
165-.favorite-actions {
166- display: grid;
167- grid-column: 1 / -1;
168- grid-template-columns: repeat(3, 1fr);
169- gap: 0.4rem;
170-}
171-.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; }
207+/* Each favorite is a route, then an editing strip. The two-stop trace is
208+ * shared with the add preview; it always links actual origin/destination. */
209+.favorites, .choice-list { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
210+.favorite { min-width: 0; border-radius: 0.875rem; background: var(--page); }
211+.favorite-route, .preview-route { display: flex; align-items: flex-start; gap: 0.75rem; min-width: 0; }
212+.favorite-route { padding: 1rem 0.875rem; }
213+.favorite-labels { flex: 1; min-width: 0; }
214+.line-badge { display: inline-grid; place-items: center; flex: 0 0 auto; min-width: 2.25rem; min-height: 2.25rem; vertical-align: middle; }
215+.line-badge img, .line-badge svg { display: block; width: auto; height: 2.25rem; max-width: 6rem; }
216+.route-journey { position: relative; display: grid; flex: 1; gap: 0.5rem; min-width: 0; }
217+.route-journey::before { content: ""; position: absolute; top: 0.6rem; bottom: 0.5rem; left: 0.2rem; width: 1px; background: var(--input-line); }
218+.route-stop, .route-destination { position: relative; display: block; padding-left: 1rem; overflow-wrap: anywhere; }
219+.route-stop { color: var(--heading); font-size: 0.9375rem; font-weight: 750; line-height: 1.4; }
220+.route-destination { color: var(--muted); font-size: 0.8125rem; line-height: 1.4; }
221+.route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); }
222+.route-stop::before { background: var(--muted); }
223+.unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; }
224+.favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; }
225+.favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; }
226+.favorite-actions { display: flex; }
227+.icon-button { display: grid; place-items: center; flex: 0 0 2.75rem; width: 2.75rem; height: 2.75rem; padding: 0; background: transparent; }
228+.icon-button svg { width: 1.25rem; height: 1.25rem; }
229+.favorite-actions .danger { margin-left: 0.125rem; border-left-color: var(--line); border-radius: 0 0.5rem 0.5rem 0; }
230+.empty-state { padding: 1rem; border-radius: 0.875rem; background: var(--page); color: var(--muted); font-size: 0.875rem; line-height: 1.6; }
172231
173-.check-row {
174- display: flex;
175- align-items: flex-start;
176- gap: 0.55rem;
177- margin: 0.35rem 0 0.55rem;
178- color: var(--navy);
179- font-size: 0.9rem;
180-}
181-.check-row input {
182- width: 1.15rem;
183- min-height: 0;
184- height: 1.15rem;
185- margin-top: 0.12rem;
186- padding: 0;
187- accent-color: var(--blue);
188- flex: none;
189-}
190-
191-.choice-list { margin-top: 0.6rem; }
192-.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; }
193-
194-.preview {
195- margin-top: 0.85rem;
196- padding: 0.8rem;
197- border-left: 0.3rem solid var(--red);
198- background: var(--blue-soft);
199-}
200-.preview p { margin-bottom: 0.35rem; }
201-.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; }
202-.preview .line-badge img, .preview .line-badge svg { height: 2rem; }
203-.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
204-.destination { padding-left: 2.85rem; color: var(--muted); }
205-.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
206-.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); }
207-
208-footer {
209- position: sticky;
210- bottom: 0;
211- padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
212- background: rgba(244, 247, 249, 0.96);
213- border-top: 1px solid var(--line);
214-}
215-.footer-actions {
216- display: grid;
217- grid-template-columns: auto minmax(0, 1fr);
218- gap: 0.5rem;
219- width: min(100%, 36.5rem);
220- margin: 0 auto;
221-}
222-.primary { display: block; width: 100%; background: var(--red); color: var(--white); }
223-
224-.about-view { overflow-wrap: anywhere; }
225-.about-heading { margin-top: 1rem; }
226-.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; }
227-.about-view p { margin-bottom: 0.65rem; }
228-
229-.visually-hidden {
230- position: absolute;
231- width: 1px;
232- height: 1px;
233- padding: 0;
234- margin: -1px;
235- overflow: hidden;
236- clip: rect(0, 0, 0, 0);
237- white-space: nowrap;
238- border: 0;
239-}
232+/* Search results are navigation rows, not another stack of form buttons. */
233+.search-field { display: flex; align-items: center; gap: 0.25rem; padding-left: 0.875rem; border: 1px solid var(--input-line); border-radius: 0.75rem; background: var(--page); }
234+.search-field > svg { flex: 0 0 1.125rem; width: 1.125rem; height: 1.125rem; color: var(--muted); stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
235+.search-field input { border-color: transparent; background: transparent; }
236+#search-hint { margin-top: 0.5rem; font-size: 0.75rem; }
237+#catalog-status { margin-top: 0.75rem; }
238+.choice-list { gap: 0; margin-top: 0.5rem; }
239+.choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; }
240+.choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); }
241+.choice-labels { display: grid; gap: 0.25rem; min-width: 0; }
242+.choice-labels strong { font-size: 0.875rem; font-weight: 700; }
243+.choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; }
244+/* Search rows repeat the official badges as a compact wrapping strip; only
245+ * this strip scales down, favorites and the preview keep the 2.25rem badges.
246+ * The 1.75rem height keeps fallback lettering around 12px, and the wide
247+ * cap only guards overflow: wide pictograms and nine-character BUS plates
248+ * keep their height, and the strip wraps without overflowing at 320px.
249+ * Official raster pictograms with black lettering sit on a small white plate
250+ * to stay legible on the dark surface; fallback SVGs keep their official
251+ * colors and get no plate. */
252+.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
253+.choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; }
254+.choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; }
255+.choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; }
256+#service-step { margin-top: 1.25rem; }
257+.preview { margin-top: 1.25rem; padding: 1rem; border-radius: 0.875rem; background: var(--page); }
258+.eyebrow { margin-bottom: 0.875rem; color: var(--accent-strong); font-family: var(--font-utility); font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
259+.recorded-preview { margin-top: 0.875rem; padding-top: 0.75rem; border-top: 1px solid var(--line); color: var(--muted); font-family: var(--font-utility); font-size: 0.6875rem; line-height: 1.6; font-variant-numeric: tabular-nums; }
260+.secondary { width: 100%; min-height: 3rem; margin-top: 0.75rem; font-size: 0.875rem; }
261+.secondary::before { content: "+"; margin-right: 0.5rem; font-size: 1.125rem; font-weight: 400; }
262+
263+.check-row { display: flex; align-items: flex-start; gap: 0.75rem; min-height: 2.75rem; padding: 0.75rem; border-radius: 0.75rem; background: var(--page); color: var(--heading); font-size: 0.875rem; cursor: pointer; }
264+.check-row input { flex: 0 0 1.125rem; width: 1.125rem; min-height: 0; height: 1.125rem; margin: 0.125rem 0 0; padding: 0; accent-color: var(--accent); }
265+#sync-hint { margin-top: 0.75rem; }
240266
267+/* The sticky save tray stays in document flow, so the final section can
268+ * scroll completely above it. Safe-area padding also covers home indicators. */
269+footer { position: sticky; bottom: 0; z-index: 1; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: var(--page); }
270+.footer-actions { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.5rem; width: min(100%, 36rem); margin: 0 auto; }
271+#about-open { padding-inline: 0.625rem; background: transparent; color: var(--muted); font-size: 0.875rem; }
272+.primary { width: 100%; min-height: 3.25rem; border-radius: 0.875rem; font-size: 0.9375rem; }
273+
274+/* About is a reading page, not a fifth settings card. Legal wording and
275+ * links remain unchanged; rules and spacing separate its actual topics. */
276+.about-view { padding: 0 0.25rem 1.5rem; overflow-wrap: anywhere; }
277+#about-back { position: sticky; top: max(0.5rem, env(safe-area-inset-top)); z-index: 1; margin-bottom: 1.5rem; padding-inline: 0.5rem; background: var(--page); color: var(--accent-strong); font-size: 0.875rem; }
278+.back-link::before { content: "←"; margin-right: 0.625rem; font-size: 1.125rem; }
279+.about-heading { margin-bottom: 0.875rem; }
280+.about-heading h2 { max-width: 26rem; font-size: 2rem; font-weight: 800; letter-spacing: -0.045em; line-height: 1.15; }
281+.about-view .about-version { display: inline-block; margin: 0 0 0.25rem; padding: 0.375rem 0.625rem; border-radius: 0.375rem; background: var(--surface); color: var(--muted); font-family: var(--font-utility); font-size: 0.75rem; line-height: 1.5; }
282+.about-view h3 { margin: 2rem 0 0.625rem; padding-top: 1.5rem; border-top: 1px solid var(--input-line); font-size: 1.1875rem; font-weight: 800; letter-spacing: -0.025em; line-height: 1.35; }
283+.about-view p { margin-bottom: 0.875rem; font-size: 0.9375rem; line-height: 1.75; }
284+.about-view p strong { font-weight: 650; }
285+
286+.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
241287 [hidden] { display: none !important; }
242288
243-@media (min-width: 32rem) {
244- main { padding: 1rem; }
245- section { padding: 1.2rem; }
246- .masthead { padding-inline: calc((100% - 36rem) / 2); }
289+@media (hover: hover) {
290+ .settings-section > summary:hover h2 { color: var(--accent-strong); }
291+ .settings-section > summary:hover .disclosure { background: var(--accent); }
292+ .settings-section > summary:hover .disclosure::after { border-color: var(--on-accent); }
293+ .choice:hover, .icon-button:not(:disabled):hover { background: var(--surface-soft); }
294+ .link-action:hover, .primary:not(:disabled):hover, .secondary:not(:disabled):hover { filter: brightness(0.95); }
295+}
296+
297+@media (max-width: 21.25em) {
298+ main { padding: 1.25rem 0.75rem; }
299+ .app-header { padding-inline: 1rem; }
300+ .settings-section > summary { padding-inline: 1rem; }
301+ .section-body { padding-inline: 1rem; }
302+ .summary-copy h2 { font-size: 1rem; }
303+ .key-links { column-gap: 0.5rem; }
304+ .favorite-route { padding: 0.875rem 0.75rem; gap: 0.625rem; }
305+ .preview { padding: 0.875rem; }
306+ footer { padding-inline: max(0.75rem, env(safe-area-inset-left)); }
307+ .primary { padding-inline: 0.625rem; font-size: 0.875rem; }
308+}
309+
310+@media (min-width: 38rem) {
311+ main { padding-block: 2rem; }
312+ .page-heading { padding-bottom: 1.5rem; }
313+ .about-view { padding-inline: 1rem; }
247314 }
248315
249316 @media (prefers-reduced-motion: reduce) {
250- *, *::before, *::after { scroll-behavior: auto !important; }
317+ *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
251318 }
@@ -1,251 +1,318 @@
1+/* Lapin Futé companion: a settings sheet and a small route board.
2+ * Pebble red and achromatic neutrals follow repebble.com; text, field borders,
3+ * and focus tones are adapted for contrast. Official line colors stay separate.
4+ */
1 :root {5 :root {
2 color-scheme: light;6 color-scheme: light;
3- --navy: #102a43;7+ --page: #f5f5f5;
4- --blue: #1769aa;8+ --surface: #ffffff;
5- --blue-soft: #eaf3fa;9+ --surface-soft: #f5f5f5;
6- --red: #c7353a;10+ --text: #171717;
7- --ink: #17212b;11+ --heading: #0a0a0a;
8- --muted: #52616f;12+ --muted: #666666;
9- --line: #cad6e0;13+ --line: #e5e5e5;
10- --paper: #f4f7f9;14+ --input-line: #8a8a8a;
11- --white: #ffffff;15+ --accent: #fa4a36;
12- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;16+ --accent-strong: #c73a28;
17+ --on-accent: #0a0a0a;
18+ --danger: #c90012;
19+ --danger-soft: #fff0f1;
20+ --focus: #c73a28;
21+ --font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
22+ --font-heading: "Avenir Next", Avenir, "Trebuchet MS", system-ui, sans-serif;
23+ --font-utility: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
13 }24 }
14 25
15-* { box-sizing: border-box; }26+@media (prefers-color-scheme: dark) {
27+ :root {
28+ color-scheme: dark;
29+ --page: #0a0a0a;
30+ --surface: #171717;
31+ --surface-soft: #262626;
32+ --text: #e5e5e5;
33+ --heading: #fafafa;
34+ --muted: #a1a1a1;
35+ --line: #333333;
36+ --input-line: #737373;
37+ --accent: #fa4a36;
38+ --accent-strong: #ff7867;
39+ --on-accent: #0a0a0a;
40+ --danger: #ff6568;
41+ --danger-soft: #34191b;
42+ --focus: #fa4a36;
43+ }
44+}
16 45
46+* { box-sizing: border-box; }
47+html { scroll-padding-block: 1rem calc(7rem + env(safe-area-inset-bottom)); }
17 html:not(.ready) body { visibility: hidden; }48 html:not(.ready) body { visibility: hidden; }
18-
19 body {49 body {
20- margin: 0;50+ display: flex;
51+ flex-direction: column;
21 min-width: 18rem;52 min-width: 18rem;
22- background: var(--paper);53+ min-height: 100vh;
23- color: var(--ink);54+ min-height: 100svh;
55+ margin: 0;
56+ background: var(--page);
57+ color: var(--text);
58+ font-family: var(--font-body);
24 font-size: 1rem;59 font-size: 1rem;
25- line-height: 1.45;60+ line-height: 1.5;
26 }61 }
27-
28 button, input, select { font: inherit; }62 button, input, select { font: inherit; }
29-button, a { -webkit-tap-highlight-color: transparent; }63+button, a, summary { -webkit-tap-highlight-color: transparent; }
64+h1, h2, h3, p { margin: 0; }
65+h1, h2, h3 { color: var(--heading); font-family: var(--font-heading); }
66+a { color: var(--accent-strong); text-underline-offset: 0.2em; }
30 67
31-.masthead {68+/* The app bar is branding, not a hero. Settings gets its own page heading. */
69+.app-header {
32 display: flex;70 display: flex;
33- gap: 0.875rem;
34 align-items: center;71 align-items: center;
35- padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem;72+ justify-content: space-between;
36- background: var(--navy);73+ gap: 1rem;
37- color: var(--white);74+ padding: max(0.875rem, env(safe-area-inset-top)) max(1.25rem, calc((100% - 35.5rem) / 2)) 0.875rem;
38- border-bottom: 0.25rem solid var(--red);75+ background: var(--surface);
39 }76 }
40-77+.app-brand { display: flex; align-items: center; gap: 0.5rem; }
41-.route-mark {78+.app-brand > span {
42- display: inline-grid;79+ color: var(--heading);
43- place-items: center;80+ font-family: var(--font-heading);
44- flex: 0 0 auto;81+ font-size: 1.25rem;
45- min-width: 2.5rem;
46- min-height: 2.5rem;
47- padding: 0.25rem 0.5rem;
48- border-radius: 0.35rem;
49- background: var(--blue);
50- color: var(--white);
51 font-weight: 800;82 font-weight: 800;
52- letter-spacing: -0.02em;83+ letter-spacing: -0.045em;
53 }84 }
85+.brand-mark { width: 2rem; height: 2rem; }
86+main { flex: 1; width: min(100%, 38rem); margin: 0 auto; padding: 1.5rem 1rem; }
87+.page-heading { padding: 0 0.25rem 1.25rem; }
88+h1 { font-size: 1.875rem; font-weight: 800; letter-spacing: -0.045em; line-height: 1.15; }
89+.page-heading p { max-width: 30rem; margin-top: 0.5rem; color: var(--muted); font-size: 0.875rem; line-height: 1.55; }
54 90
55-.line-badge {91+/* One sheet, four independent native disclosures. The caption and pictogram
56- display: inline-grid;92+ * identify the section even when its controls are folded away. */
93+.settings-list { background: var(--surface); border-radius: 1.25rem; }
94+.settings-section { border-bottom: 1px solid var(--line); }
95+.settings-section:last-child { border-bottom: 0; }
96+.settings-section > summary {
97+ display: grid;
98+ grid-template-columns: 2.25rem minmax(0, 1fr) 1.5rem;
99+ align-items: center;
100+ gap: 0.75rem;
101+ min-height: 5.25rem;
102+ padding: 1.125rem 1.25rem;
103+ cursor: pointer;
104+ list-style: none;
105+}
106+.settings-section > summary::-webkit-details-marker { display: none; }
107+.settings-section > summary::marker { content: ""; }
108+.settings-section:first-child > summary { border-radius: 1.25rem 1.25rem 0 0; }
109+.settings-section:last-child:not([open]) > summary { border-radius: 0 0 1.25rem 1.25rem; }
110+.section-icon {
111+ display: grid;
57 place-items: center;112 place-items: center;
58- flex: 0 0 auto;113+ width: 2.25rem;
59- min-width: 2.5rem;114+ height: 2.25rem;
60- min-height: 2.5rem;115+ border-radius: 0.75rem;
61- vertical-align: middle;116+ background: var(--surface-soft);
117+ color: var(--accent-strong);
62 }118 }
63-119+.section-icon svg { width: 1.35rem; height: 1.35rem; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
64-.line-badge img, .line-badge svg {120+.summary-copy { min-width: 0; }
121+.summary-copy h2 { font-size: 1.0625rem; font-weight: 800; line-height: 1.3; letter-spacing: -0.025em; overflow-wrap: anywhere; }
122+.section-caption, .summary-hint, .favorite-capacity {
65 display: block;123 display: block;
66- width: auto;124+ margin-top: 0.25rem;
67- height: 2.5rem;125+ color: var(--muted);
68- max-width: 8rem;126+ font-size: 0.75rem;
69-}127+ line-height: 1.45;
70-128+ overflow-wrap: anywhere;
71-h1, h2, p { margin-top: 0; }
72-h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; }
73-.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; }
74-
75-main {
76- width: min(100%, 38rem);
77- margin: 0 auto;
78- padding: 0.75rem;
79-}
80-
81-section {
82- margin-bottom: 0.75rem;
83- padding: 1rem;
84- background: var(--white);
85- border: 1px solid var(--line);
86- border-radius: 0.5rem;
87 }129 }
88-130+.summary-hint { color: var(--accent-strong); }
89-.section-heading {131+.favorite-capacity { font-variant-numeric: tabular-nums; }
90- display: flex;132+.disclosure { display: grid; place-items: center; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--page); }
91- align-items: baseline;133+.disclosure::after {
92- gap: 0.625rem;134+ content: "";
93- margin-bottom: 0.75rem;135+ width: 0.4rem;
94- border-bottom: 1px solid var(--line);136+ height: 0.4rem;
95-}137+ margin-top: -0.2rem;
96-138+ border-right: 1.5px solid var(--muted);
97-.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; }139+ border-bottom: 1.5px solid var(--muted);
98-h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; }140+ transform: rotate(45deg);
99-h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; }141+ transition: transform 0.16s ease;
100-
101-.field-label {
102- display: block;
103- margin: 0.8rem 0 0.35rem;
104- color: var(--navy);
105- font-size: 0.88rem;
106- font-weight: 700;
107 }142 }
143+.settings-section[open] > summary { padding-bottom: 0.875rem; }
144+.settings-section[open] .section-icon { background: var(--accent); color: var(--on-accent); }
145+.settings-section[open] .disclosure::after { margin-top: 0.15rem; transform: rotate(225deg); }
146+.settings-section[open] .summary-hint, .settings-section[open] .section-caption { display: none; }
147+.section-body { padding: 0 1.25rem 1.25rem; }
108 148
149+/* Filled fields keep the chrome quiet without disguising editable values. */
150+.field-label { display: block; margin: 1rem 0 0.5rem; color: var(--heading); font-size: 0.8125rem; font-weight: 700; }
151+.section-body > .field-label:first-child { margin-top: 0; }
109 input, select {152 input, select {
110 width: 100%;153 width: 100%;
111- min-height: 2.75rem;154+ min-width: 0;
112- padding: 0.65rem 0.75rem;155+ min-height: 3rem;
113- border: 1px solid #8fa3b5;156+ padding: 0.625rem 0.75rem;
114- border-radius: 0.35rem;157+ border: 1px solid var(--input-line);
115- background: var(--white);158+ border-radius: 0.625rem;
116- color: var(--ink);159+ background: var(--surface);
160+ color: var(--text);
161+ font-size: 1rem;
117 }162 }
118-163+input::placeholder { color: var(--muted); opacity: 1; }
119-input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible {164+input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible, summary:focus-visible {
120- outline: 0.2rem solid #ffb81c;165+ outline: 3px solid var(--focus);
121- outline-offset: 0.15rem;166+ outline-offset: 3px;
122 }167 }
123-168+summary:focus-visible { outline-offset: -3px; }
124-.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }169+.status, .support { color: var(--muted); font-size: 0.8125rem; line-height: 1.55; }
125-.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; }170+.status:empty { display: none; }
126-.status, .support { color: var(--muted); }171+.pending, .error { margin-top: 0.75rem; font-size: 0.8125rem; font-weight: 600; line-height: 1.5; }
127-.pending { color: var(--blue); font-weight: 700; }172+.pending { color: var(--accent-strong); }
128-.error { color: #9b1c24; font-weight: 700; }173+.error { padding: 0.75rem; border-radius: 0.625rem; background: var(--danger-soft); color: var(--danger); }
129-a { color: var(--blue); font-weight: 700; }174+#favorites-status { margin: 0 0 0.75rem; }
130 175
131 button {176 button {
177+ min-width: 0;
132 min-height: 2.75rem;178 min-height: 2.75rem;
133- padding: 0.55rem 0.8rem;179+ padding: 0.625rem 0.875rem;
134 border: 1px solid transparent;180 border: 1px solid transparent;
135- border-radius: 0.35rem;181+ border-radius: 0.625rem;
136 cursor: pointer;182 cursor: pointer;
137- font-weight: 750;183+ font-weight: 700;
184+ line-height: 1.35;
185+ overflow-wrap: anywhere;
138 }186 }
139-button:disabled { cursor: default; opacity: 0.45; }187+button:disabled { cursor: default; opacity: 0.35; }
140-.quiet { border-color: var(--line); background: var(--white); color: var(--navy); }188+.quiet { background: var(--surface-soft); color: var(--heading); }
141-.danger { color: #9b1c24; }189+.danger { color: var(--danger); }
142-#key-remove { margin-top: 0.25rem; }190+.primary, .secondary { background: var(--accent); color: var(--on-accent); }
143 191
144-.favorites, .choice-list {192+/* Access guidance leads to creation and entry; storage detail is a footnote. */
145- display: grid;193+.requirement { font-size: 0.875rem; line-height: 1.6; }
146- gap: 0.55rem;194+.key-links { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.75rem; margin-top: 0.75rem; }
147- margin: 0;195+.link-action, .link-secondary { display: inline-flex; align-items: center; min-height: 2.75rem; font-size: 0.8125rem; font-weight: 700; }
148- padding: 0;196+.link-action { gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 0.625rem; background: var(--surface-soft); color: var(--accent-strong); text-decoration: none; }
149- list-style: none;197+.link-action::after { content: "↗"; font-size: 1.1rem; line-height: 1; }
150-}198+.link-secondary { color: var(--muted); }
199+.key-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.25rem; border: 1px solid var(--input-line); border-radius: 0.75rem; background: var(--page); }
200+.key-row input { min-height: 2.75rem; padding-inline: 0.5rem; border-color: transparent; background: transparent; }
201+#key-toggle { background: var(--surface); font-size: 0.8125rem; }
202+#key-remove { margin-top: 0.75rem; background: var(--danger-soft); font-size: 0.8125rem; }
203+.key-note { margin-top: 1rem; padding: 0.75rem 0.875rem; border-radius: 0.75rem; background: var(--page); }
204+.key-note .status { color: var(--heading); font-weight: 600; }
205+.key-note .support { margin-top: 0.25rem; font-size: 0.75rem; }
151 206
152-.favorite {207+/* Each favorite is a route, then an editing strip. The two-stop trace is
153- display: grid;208+ * shared with the add preview; it always links actual origin/destination. */
154- grid-template-columns: auto 1fr;209+.favorites, .choice-list { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
155- gap: 0.65rem;210+.favorite { min-width: 0; border-radius: 0.875rem; background: var(--page); }
156- padding: 0.75rem 0;211+.favorite-route, .preview-route { display: flex; align-items: flex-start; gap: 0.75rem; min-width: 0; }
157- border-bottom: 1px solid var(--line);212+.favorite-route { padding: 1rem 0.875rem; }
158-}213+.favorite-labels { flex: 1; min-width: 0; }
159-.favorite:last-child { border-bottom: 0; }214+.line-badge { display: inline-grid; place-items: center; flex: 0 0 auto; min-width: 2.25rem; min-height: 2.25rem; vertical-align: middle; }
160-.favorite-labels { display: grid; align-content: center; min-width: 0; }215+.line-badge img, .line-badge svg { display: block; width: auto; height: 2.25rem; max-width: 6rem; }
161-.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; }216+.route-journey { position: relative; display: grid; flex: 1; gap: 0.5rem; min-width: 0; }
162-.favorite-labels span { color: var(--muted); font-size: 0.85rem; }217+.route-journey::before { content: ""; position: absolute; top: 0.6rem; bottom: 0.5rem; left: 0.2rem; width: 1px; background: var(--input-line); }
163-.favorite-labels .unresolved { color: #9b1c24; font-size: 0.78rem; font-weight: 700; }218+.route-stop, .route-destination { position: relative; display: block; padding-left: 1rem; overflow-wrap: anywhere; }
164-.favorite > input { grid-column: 1 / -1; }219+.route-stop { color: var(--heading); font-size: 0.9375rem; font-weight: 750; line-height: 1.4; }
165-.favorite-actions {220+.route-destination { color: var(--muted); font-size: 0.8125rem; line-height: 1.4; }
166- display: grid;221+.route-stop::before, .route-destination::before { content: ""; position: absolute; top: 0.4rem; left: 0; width: 0.45rem; height: 0.45rem; border: 1.5px solid var(--muted); border-radius: 50%; background: var(--page); }
167- grid-column: 1 / -1;222+.route-stop::before { background: var(--muted); }
168- grid-template-columns: repeat(3, 1fr);223+.unresolved { display: block; margin-top: 0.5rem; color: var(--danger); font-size: 0.75rem; line-height: 1.5; }
169- gap: 0.4rem;224+.favorite-edit { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.25rem; padding: 0.5rem; border-top: 1px solid var(--line); border-radius: 0 0 0.875rem 0.875rem; }
170-}225+.favorite-name { min-height: 2.75rem; padding: 0.5rem; border-color: transparent; border-radius: 0.5rem; }
171-.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; }226+.favorite-actions { display: flex; }
227+.icon-button { display: grid; place-items: center; flex: 0 0 2.75rem; width: 2.75rem; height: 2.75rem; padding: 0; background: transparent; }
228+.icon-button svg { width: 1.25rem; height: 1.25rem; }
229+.favorite-actions .danger { margin-left: 0.125rem; border-left-color: var(--line); border-radius: 0 0.5rem 0.5rem 0; }
230+.empty-state { padding: 1rem; border-radius: 0.875rem; background: var(--page); color: var(--muted); font-size: 0.875rem; line-height: 1.6; }
172 231
173-.check-row {232+/* Search results are navigation rows, not another stack of form buttons. */
174- display: flex;233+.search-field { display: flex; align-items: center; gap: 0.25rem; padding-left: 0.875rem; border: 1px solid var(--input-line); border-radius: 0.75rem; background: var(--page); }
175- align-items: flex-start;234+.search-field > svg { flex: 0 0 1.125rem; width: 1.125rem; height: 1.125rem; color: var(--muted); stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
176- gap: 0.55rem;235+.search-field input { border-color: transparent; background: transparent; }
177- margin: 0.35rem 0 0.55rem;236+#search-hint { margin-top: 0.5rem; font-size: 0.75rem; }
178- color: var(--navy);237+#catalog-status { margin-top: 0.75rem; }
179- font-size: 0.9rem;238+.choice-list { gap: 0; margin-top: 0.5rem; }
180-}239+.choice { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; padding: 0.875rem 0.25rem; border-bottom-color: var(--line); border-radius: 0; background: transparent; color: var(--heading); text-align: left; }
181-.check-row input {240+.choice::after { content: ""; flex: 0 0 0.45rem; width: 0.45rem; height: 0.45rem; margin-right: 0.375rem; border-top: 1.5px solid var(--muted); border-right: 1.5px solid var(--muted); transform: rotate(45deg); }
182- width: 1.15rem;241+.choice-labels { display: grid; gap: 0.25rem; min-width: 0; }
183- min-height: 0;242+.choice-labels strong { font-size: 0.875rem; font-weight: 700; }
184- height: 1.15rem;243+.choice-context { color: var(--muted); font-size: 0.75rem; font-weight: 400; }
185- margin-top: 0.12rem;244+/* Search rows repeat the official badges as a compact wrapping strip; only
186- padding: 0;245+ * this strip scales down, favorites and the preview keep the 2.25rem badges.
187- accent-color: var(--blue);246+ * The 1.75rem height keeps fallback lettering around 12px, and the wide
188- flex: none;247+ * cap only guards overflow: wide pictograms and nine-character BUS plates
189-}248+ * keep their height, and the strip wraps without overflowing at 320px.
190-249+ * Official raster pictograms with black lettering sit on a small white plate
191-.choice-list { margin-top: 0.6rem; }250+ * to stay legible on the dark surface; fallback SVGs keep their official
192-.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; }251+ * colors and get no plate. */
193-252+.choice-lines { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
194-.preview {253+.choice-lines .line-badge { min-width: 1.75rem; min-height: 1.75rem; }
195- margin-top: 0.85rem;254+.choice-lines .line-badge img, .choice-lines .line-badge svg { height: 1.75rem; max-width: 8rem; }
196- padding: 0.8rem;255+.choice-lines .line-badge img { padding: 0.0625rem; border-radius: 0.25rem; background: #ffffff; }
197- border-left: 0.3rem solid var(--red);256+#service-step { margin-top: 1.25rem; }
198- background: var(--blue-soft);257+.preview { margin-top: 1.25rem; padding: 1rem; border-radius: 0.875rem; background: var(--page); }
199-}258+.eyebrow { margin-bottom: 0.875rem; color: var(--accent-strong); font-family: var(--font-utility); font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
200-.preview p { margin-bottom: 0.35rem; }259+.recorded-preview { margin-top: 0.875rem; padding-top: 0.75rem; border-top: 1px solid var(--line); color: var(--muted); font-family: var(--font-utility); font-size: 0.6875rem; line-height: 1.6; font-variant-numeric: tabular-nums; }
201-.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; }260+.secondary { width: 100%; min-height: 3rem; margin-top: 0.75rem; font-size: 0.875rem; }
202-.preview .line-badge img, .preview .line-badge svg { height: 2rem; }261+.secondary::before { content: "+"; margin-right: 0.5rem; font-size: 1.125rem; font-weight: 400; }
203-.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }262+
204-.destination { padding-left: 2.85rem; color: var(--muted); }263+.check-row { display: flex; align-items: flex-start; gap: 0.75rem; min-height: 2.75rem; padding: 0.75rem; border-radius: 0.75rem; background: var(--page); color: var(--heading); font-size: 0.875rem; cursor: pointer; }
205-.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }264+.check-row input { flex: 0 0 1.125rem; width: 1.125rem; min-height: 0; height: 1.125rem; margin: 0.125rem 0 0; padding: 0; accent-color: var(--accent); }
206-.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); }265+#sync-hint { margin-top: 0.75rem; }
207-
208-footer {
209- position: sticky;
210- bottom: 0;
211- padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
212- background: rgba(244, 247, 249, 0.96);
213- border-top: 1px solid var(--line);
214-}
215-.footer-actions {
216- display: grid;
217- grid-template-columns: auto minmax(0, 1fr);
218- gap: 0.5rem;
219- width: min(100%, 36.5rem);
220- margin: 0 auto;
221-}
222-.primary { display: block; width: 100%; background: var(--red); color: var(--white); }
223-
224-.about-view { overflow-wrap: anywhere; }
225-.about-heading { margin-top: 1rem; }
226-.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; }
227-.about-view p { margin-bottom: 0.65rem; }
228-
229-.visually-hidden {
230- position: absolute;
231- width: 1px;
232- height: 1px;
233- padding: 0;
234- margin: -1px;
235- overflow: hidden;
236- clip: rect(0, 0, 0, 0);
237- white-space: nowrap;
238- border: 0;
239-}
240 266
267+/* The sticky save tray stays in document flow, so the final section can
268+ * scroll completely above it. Safe-area padding also covers home indicators. */
269+footer { position: sticky; bottom: 0; z-index: 1; padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: var(--page); }
270+.footer-actions { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.5rem; width: min(100%, 36rem); margin: 0 auto; }
271+#about-open { padding-inline: 0.625rem; background: transparent; color: var(--muted); font-size: 0.875rem; }
272+.primary { width: 100%; min-height: 3.25rem; border-radius: 0.875rem; font-size: 0.9375rem; }
273+
274+/* About is a reading page, not a fifth settings card. Legal wording and
275+ * links remain unchanged; rules and spacing separate its actual topics. */
276+.about-view { padding: 0 0.25rem 1.5rem; overflow-wrap: anywhere; }
277+#about-back { position: sticky; top: max(0.5rem, env(safe-area-inset-top)); z-index: 1; margin-bottom: 1.5rem; padding-inline: 0.5rem; background: var(--page); color: var(--accent-strong); font-size: 0.875rem; }
278+.back-link::before { content: "←"; margin-right: 0.625rem; font-size: 1.125rem; }
279+.about-heading { margin-bottom: 0.875rem; }
280+.about-heading h2 { max-width: 26rem; font-size: 2rem; font-weight: 800; letter-spacing: -0.045em; line-height: 1.15; }
281+.about-view .about-version { display: inline-block; margin: 0 0 0.25rem; padding: 0.375rem 0.625rem; border-radius: 0.375rem; background: var(--surface); color: var(--muted); font-family: var(--font-utility); font-size: 0.75rem; line-height: 1.5; }
282+.about-view h3 { margin: 2rem 0 0.625rem; padding-top: 1.5rem; border-top: 1px solid var(--input-line); font-size: 1.1875rem; font-weight: 800; letter-spacing: -0.025em; line-height: 1.35; }
283+.about-view p { margin-bottom: 0.875rem; font-size: 0.9375rem; line-height: 1.75; }
284+.about-view p strong { font-weight: 650; }
285+
286+.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
241 [hidden] { display: none !important; }287 [hidden] { display: none !important; }
242 288
243-@media (min-width: 32rem) {289+@media (hover: hover) {
244- main { padding: 1rem; }290+ .settings-section > summary:hover h2 { color: var(--accent-strong); }
245- section { padding: 1.2rem; }291+ .settings-section > summary:hover .disclosure { background: var(--accent); }
246- .masthead { padding-inline: calc((100% - 36rem) / 2); }292+ .settings-section > summary:hover .disclosure::after { border-color: var(--on-accent); }
293+ .choice:hover, .icon-button:not(:disabled):hover { background: var(--surface-soft); }
294+ .link-action:hover, .primary:not(:disabled):hover, .secondary:not(:disabled):hover { filter: brightness(0.95); }
295+}
296+
297+@media (max-width: 21.25em) {
298+ main { padding: 1.25rem 0.75rem; }
299+ .app-header { padding-inline: 1rem; }
300+ .settings-section > summary { padding-inline: 1rem; }
301+ .section-body { padding-inline: 1rem; }
302+ .summary-copy h2 { font-size: 1rem; }
303+ .key-links { column-gap: 0.5rem; }
304+ .favorite-route { padding: 0.875rem 0.75rem; gap: 0.625rem; }
305+ .preview { padding: 0.875rem; }
306+ footer { padding-inline: max(0.75rem, env(safe-area-inset-left)); }
307+ .primary { padding-inline: 0.625rem; font-size: 0.875rem; }
308+}
309+
310+@media (min-width: 38rem) {
311+ main { padding-block: 2rem; }
312+ .page-heading { padding-bottom: 1.5rem; }
313+ .about-view { padding-inline: 1rem; }
247 }314 }
248 315
249 @media (prefers-reduced-motion: reduce) {316 @media (prefers-reduced-motion: reduce) {
250- *, *::before, *::after { scroll-behavior: auto !important; }317+ *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
251 }318 }
modified packages/config-page/test/catalog-client.test.js +42 -0
@@ -14,6 +14,7 @@ const manifest = {
1414 function place(index, stopLabel = "Châtelet", localityLabel = "Paris") {
1515 return {
1616 placeId: `plc_${String(index).padStart(43, "0")}`, stopLabel, localityLabel, mode: "BUS",
17+ lines: [{ lineLabel: "21", lineColor: "#0064b0", lineTextColor: "#ffffff" }],
1718 searchText: normalizeCatalogSearchText(`${stopLabel} ${localityLabel}`),
1819 };
1920 }
@@ -67,6 +68,47 @@ test("static search scans every page, keeps the best twenty and preserves homony
6768 assert.deepEqual(requests, ["catalog/manifest.json", `catalog/${revision}/search/63_68/0.json`, `catalog/${revision}/search/63_68/1.json`]);
6869 });
6970
71+test("search rows carry precomputed lines without extra requests and reject malformed metadata", async () => {
72+ const enriched = place(1, "Saint-Denis - Université");
73+ enriched.lines = [
74+ { lineLabel: "1611", lineColor: "#009645", lineTextColor: "#ffffff" },
75+ { lineLabel: "253", lineColor: "#6f4fa0", lineTextColor: "#ffffff" },
76+ ];
77+ const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
78+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [enriched] }));
79+ const results = await client.searchPlaces("universite");
80+ // searchText is stripped; every validated field, the lines included, survives.
81+ assert.deepEqual(results, [{
82+ placeId: enriched.placeId, stopLabel: "Saint-Denis - Université", localityLabel: "Paris", mode: "BUS",
83+ lines: enriched.lines,
84+ }]);
85+ // Exactly the manifest and the one search page: no per-result service fetches.
86+ assert.equal(requests.length, 2);
87+ assert.match(requests[1], /\/search\/[^/]+\/0\.json$/u);
88+
89+ const { lines: _omitted, ...withoutLines } = enriched;
90+ const sparse = { ...enriched, lines: [enriched.lines[0]] };
91+ sparse.lines.length = 2;
92+ const malformed = [
93+ withoutLines,
94+ { ...enriched, lines: [] },
95+ { ...enriched, lines: "21" },
96+ sparse,
97+ { ...enriched, lines: [{ ...enriched.lines[0], lineRef: "STIF:Line::C01611:" }] },
98+ { ...enriched, lines: [{ lineLabel: "253", lineColor: "#6f4fa0" }] },
99+ { ...enriched, lines: [{ ...enriched.lines[0], lineColor: "#6F4FA0" }] },
100+ { ...enriched, lines: [{ ...enriched.lines[0], lineTextColor: "#fff" }] },
101+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "" }] },
102+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "é".repeat(49) }] },
103+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "1611\n" }] },
104+ ];
105+ for (const entry of malformed) {
106+ const failing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
107+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [entry] }));
108+ await assert.rejects(failing.client.searchPlaces("universite"), unavailable);
109+ }
110+});
111+
70112 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {
71113 const match = place(1, "Rue Saint-Denis Châtelet");
72114 const noChatelet = place(2, "Saint-Denis");
@@ -14,6 +14,7 @@ const manifest = {
14 function place(index, stopLabel = "Châtelet", localityLabel = "Paris") {14 function place(index, stopLabel = "Châtelet", localityLabel = "Paris") {
15 return {15 return {
16 placeId: `plc_${String(index).padStart(43, "0")}`, stopLabel, localityLabel, mode: "BUS",16 placeId: `plc_${String(index).padStart(43, "0")}`, stopLabel, localityLabel, mode: "BUS",
17+ lines: [{ lineLabel: "21", lineColor: "#0064b0", lineTextColor: "#ffffff" }],
17 searchText: normalizeCatalogSearchText(`${stopLabel} ${localityLabel}`),18 searchText: normalizeCatalogSearchText(`${stopLabel} ${localityLabel}`),
18 };19 };
19 }20 }
@@ -67,6 +68,47 @@ test("static search scans every page, keeps the best twenty and preserves homony
67 assert.deepEqual(requests, ["catalog/manifest.json", `catalog/${revision}/search/63_68/0.json`, `catalog/${revision}/search/63_68/1.json`]);68 assert.deepEqual(requests, ["catalog/manifest.json", `catalog/${revision}/search/63_68/0.json`, `catalog/${revision}/search/63_68/1.json`]);
68 });69 });
69 70
71+test("search rows carry precomputed lines without extra requests and reject malformed metadata", async () => {
72+ const enriched = place(1, "Saint-Denis - Université");
73+ enriched.lines = [
74+ { lineLabel: "1611", lineColor: "#009645", lineTextColor: "#ffffff" },
75+ { lineLabel: "253", lineColor: "#6f4fa0", lineTextColor: "#ffffff" },
76+ ];
77+ const { client, requests } = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
78+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [enriched] }));
79+ const results = await client.searchPlaces("universite");
80+ // searchText is stripped; every validated field, the lines included, survives.
81+ assert.deepEqual(results, [{
82+ placeId: enriched.placeId, stopLabel: "Saint-Denis - Université", localityLabel: "Paris", mode: "BUS",
83+ lines: enriched.lines,
84+ }]);
85+ // Exactly the manifest and the one search page: no per-result service fetches.
86+ assert.equal(requests.length, 2);
87+ assert.match(requests[1], /\/search\/[^/]+\/0\.json$/u);
88+
89+ const { lines: _omitted, ...withoutLines } = enriched;
90+ const sparse = { ...enriched, lines: [enriched.lines[0]] };
91+ sparse.lines.length = 2;
92+ const malformed = [
93+ withoutLines,
94+ { ...enriched, lines: [] },
95+ { ...enriched, lines: "21" },
96+ sparse,
97+ { ...enriched, lines: [{ ...enriched.lines[0], lineRef: "STIF:Line::C01611:" }] },
98+ { ...enriched, lines: [{ lineLabel: "253", lineColor: "#6f4fa0" }] },
99+ { ...enriched, lines: [{ ...enriched.lines[0], lineColor: "#6F4FA0" }] },
100+ { ...enriched, lines: [{ ...enriched.lines[0], lineTextColor: "#fff" }] },
101+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "" }] },
102+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "é".repeat(49) }] },
103+ { ...enriched, lines: [{ ...enriched.lines[0], lineLabel: "1611\n" }] },
104+ ];
105+ for (const entry of malformed) {
106+ const failing = fixtureClient((url) => url === "catalog/manifest.json" ? response(manifest)
107+ : response({ schemaVersion: 1, revision, page: 0, nextPage: null, places: [entry] }));
108+ await assert.rejects(failing.client.searchPlaces("universite"), unavailable);
109+ }
110+});
111+
70 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {112 test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => {
71 const match = place(1, "Rue Saint-Denis Châtelet");113 const match = place(1, "Rue Saint-Denis Châtelet");
72 const noChatelet = place(2, "Saint-Denis");114 const noChatelet = place(2, "Saint-Denis");
modified packages/config-page/test/config-page.test.ts +58 -2
@@ -11,6 +11,8 @@ import {
1111 isApiKeyUpdate,
1212 isFavorite,
1313 isPersonalApiKey,
14+ isPlaceLine as canonicalIsPlaceLine,
15+ isPlaceSearchItem as canonicalIsPlaceSearchItem,
1416 type Favorite,
1517 } from "../../contracts/src/index.ts";
1618 import {
@@ -29,6 +31,8 @@ import {
2931 initialConfigState,
3032 isFavoriteShape,
3133 isPhoneFavorite,
34+ isPlaceLine,
35+ isPlaceSearchItem,
3236 isPlaceSearchResult,
3337 isServiceOptionsResult,
3438 isServiceRouting,
@@ -536,11 +540,18 @@ test("three backend services can be added, renamed, reordered, and removed atomi
536540 });
537541
538542 test("catalog response guards reject identifiers and malformed or oversized collections", () => {
539- const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" };
543+ const placeLines = [{ lineLabel: "14", lineColor: "#62259d", lineTextColor: "#ffffff" }];
544+ const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO", lines: placeLines };
540545 const service = serviceOption("svc-a");
541546 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true);
542547 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false);
543548 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false);
549+ const { lines: _placeLines, ...withoutLines } = place;
550+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [withoutLines] }), false);
551+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, lines: [] }] }), false);
552+ const sparsePlaceLines: unknown[] = [placeLines[0]];
553+ sparsePlaceLines.length = 2;
554+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, lines: sparsePlaceLines }] }), false);
544555 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true);
545556 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false);
546557 const { routing: _routing, ...routingLess } = service;
@@ -830,9 +841,12 @@ test("page keeps secrets out of durable and observable surfaces", () => {
830841 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
831842 const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8");
832843 const html = readFileSync(join(here, "../index.html"), "utf8");
833- for (const source of [core, controller, client]) {
844+ for (const source of [core, client]) {
834845 assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u);
835846 }
847+ // The controller itself may persist section open/closed booleans in
848+ // localStorage; every other durable or observable surface stays banned.
849+ assert.doesNotMatch(controller, /sessionStorage|document\\.cookie|WebSocket|console\\./u);
836850 assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u);
837851 assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);
838852 assert.match(html, /connect-src 'self'/u);
@@ -899,6 +913,48 @@ test("favorites without presentation fields resolve no official image and use an
899913 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
900914 });
901915
916+test("mirrored place search validation matches the canonical lines contract", () => {
917+ const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
918+ const placeId = `plc_${"0".repeat(43)}`;
919+ const valid = {
920+ placeId,
921+ stopLabel: "Saint-Denis - Université",
922+ localityLabel: "Saint-Denis",
923+ mode: "METRO",
924+ lines: [line, { lineLabel: "1611", lineColor: "#009645", lineTextColor: "#ffffff" }],
925+ };
926+ const sparse: unknown[] = [line];
927+ sparse.length = 2;
928+ const cases: Array<[unknown, boolean]> = [
929+ [valid, true],
930+ [{ ...valid, localityLabel: undefined }, true],
931+ [{ ...valid, lines: [{ ...line, lineLabel: "x".repeat(96) }] }, true],
932+ [{ ...valid, lines: [{ ...line, lineLabel: "é".repeat(48) }] }, true],
933+ [{ ...valid, lines: [] }, false],
934+ [{ ...valid, lines: undefined }, false],
935+ [{ ...valid, lines: "13" }, false],
936+ [{ ...valid, lines: sparse }, false],
937+ [{ ...valid, lines: [null] }, false],
938+ [{ ...valid, lines: [{ ...line, lineRef: "STIF:Line::C01393:" }] }, false],
939+ [{ ...valid, lines: [{ lineLabel: "13", lineColor: "#82c8e6" }] }, false],
940+ [{ ...valid, lines: [{ ...line, lineColor: "#82C8E6" }] }, false],
941+ [{ ...valid, lines: [{ ...line, lineTextColor: "#fff" }] }, false],
942+ [{ ...valid, lines: [{ ...line, lineLabel: "" }] }, false],
943+ [{ ...valid, lines: [{ ...line, lineLabel: "x".repeat(97) }] }, false],
944+ [{ ...valid, lines: [{ ...line, lineLabel: "é".repeat(49) }] }, false],
945+ [{ ...valid, lines: [{ ...line, lineLabel: "13\n" }] }, false],
946+ [{ ...valid, monitoringRef: "raw" }, false],
947+ ];
948+ for (const [value, expected] of cases) {
949+ assert.equal(isPlaceSearchItem(value), expected);
950+ assert.equal(canonicalIsPlaceSearchItem(value), expected);
951+ }
952+ assert.equal(isPlaceLine(line), true);
953+ assert.equal(canonicalIsPlaceLine(line), true);
954+ assert.equal(isPlaceLine({ ...line, extra: true }), false);
955+ assert.equal(canonicalIsPlaceLine({ ...line, extra: true }), false);
956+});
957+
902958 test("the first-load gzip metric includes the eagerly imported badge resolver", () => {
903959 const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8");
904960 assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u);
@@ -11,6 +11,8 @@ import {
11 isApiKeyUpdate,11 isApiKeyUpdate,
12 isFavorite,12 isFavorite,
13 isPersonalApiKey,13 isPersonalApiKey,
14+ isPlaceLine as canonicalIsPlaceLine,
15+ isPlaceSearchItem as canonicalIsPlaceSearchItem,
14 type Favorite,16 type Favorite,
15 } from "../../contracts/src/index.ts";17 } from "../../contracts/src/index.ts";
16 import {18 import {
@@ -29,6 +31,8 @@ import {
29 initialConfigState,31 initialConfigState,
30 isFavoriteShape,32 isFavoriteShape,
31 isPhoneFavorite,33 isPhoneFavorite,
34+ isPlaceLine,
35+ isPlaceSearchItem,
32 isPlaceSearchResult,36 isPlaceSearchResult,
33 isServiceOptionsResult,37 isServiceOptionsResult,
34 isServiceRouting,38 isServiceRouting,
@@ -536,11 +540,18 @@ test("three backend services can be added, renamed, reordered, and removed atomi
536 });540 });
537 541
538 test("catalog response guards reject identifiers and malformed or oversized collections", () => {542 test("catalog response guards reject identifiers and malformed or oversized collections", () => {
539- const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" };543+ const placeLines = [{ lineLabel: "14", lineColor: "#62259d", lineTextColor: "#ffffff" }];
544+ const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO", lines: placeLines };
540 const service = serviceOption("svc-a");545 const service = serviceOption("svc-a");
541 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true);546 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true);
542 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false);547 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false);
543 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false);548 assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false);
549+ const { lines: _placeLines, ...withoutLines } = place;
550+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [withoutLines] }), false);
551+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, lines: [] }] }), false);
552+ const sparsePlaceLines: unknown[] = [placeLines[0]];
553+ sparsePlaceLines.length = 2;
554+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, lines: sparsePlaceLines }] }), false);
544 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true);555 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true);
545 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false);556 assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false);
546 const { routing: _routing, ...routingLess } = service;557 const { routing: _routing, ...routingLess } = service;
@@ -830,9 +841,12 @@ test("page keeps secrets out of durable and observable surfaces", () => {
830 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");841 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
831 const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8");842 const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8");
832 const html = readFileSync(join(here, "../index.html"), "utf8");843 const html = readFileSync(join(here, "../index.html"), "utf8");
833- for (const source of [core, controller, client]) {844+ for (const source of [core, client]) {
834 assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u);845 assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u);
835 }846 }
847+ // The controller itself may persist section open/closed booleans in
848+ // localStorage; every other durable or observable surface stays banned.
849+ assert.doesNotMatch(controller, /sessionStorage|document\\.cookie|WebSocket|console\\./u);
836 assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u);850 assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u);
837 assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);851 assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);
838 assert.match(html, /connect-src 'self'/u);852 assert.match(html, /connect-src 'self'/u);
@@ -899,6 +913,48 @@ test("favorites without presentation fields resolve no official image and use an
899 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);913 assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
900 });914 });
901 915
916+test("mirrored place search validation matches the canonical lines contract", () => {
917+ const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
918+ const placeId = `plc_${"0".repeat(43)}`;
919+ const valid = {
920+ placeId,
921+ stopLabel: "Saint-Denis - Université",
922+ localityLabel: "Saint-Denis",
923+ mode: "METRO",
924+ lines: [line, { lineLabel: "1611", lineColor: "#009645", lineTextColor: "#ffffff" }],
925+ };
926+ const sparse: unknown[] = [line];
927+ sparse.length = 2;
928+ const cases: Array<[unknown, boolean]> = [
929+ [valid, true],
930+ [{ ...valid, localityLabel: undefined }, true],
931+ [{ ...valid, lines: [{ ...line, lineLabel: "x".repeat(96) }] }, true],
932+ [{ ...valid, lines: [{ ...line, lineLabel: "é".repeat(48) }] }, true],
933+ [{ ...valid, lines: [] }, false],
934+ [{ ...valid, lines: undefined }, false],
935+ [{ ...valid, lines: "13" }, false],
936+ [{ ...valid, lines: sparse }, false],
937+ [{ ...valid, lines: [null] }, false],
938+ [{ ...valid, lines: [{ ...line, lineRef: "STIF:Line::C01393:" }] }, false],
939+ [{ ...valid, lines: [{ lineLabel: "13", lineColor: "#82c8e6" }] }, false],
940+ [{ ...valid, lines: [{ ...line, lineColor: "#82C8E6" }] }, false],
941+ [{ ...valid, lines: [{ ...line, lineTextColor: "#fff" }] }, false],
942+ [{ ...valid, lines: [{ ...line, lineLabel: "" }] }, false],
943+ [{ ...valid, lines: [{ ...line, lineLabel: "x".repeat(97) }] }, false],
944+ [{ ...valid, lines: [{ ...line, lineLabel: "é".repeat(49) }] }, false],
945+ [{ ...valid, lines: [{ ...line, lineLabel: "13\n" }] }, false],
946+ [{ ...valid, monitoringRef: "raw" }, false],
947+ ];
948+ for (const [value, expected] of cases) {
949+ assert.equal(isPlaceSearchItem(value), expected);
950+ assert.equal(canonicalIsPlaceSearchItem(value), expected);
951+ }
952+ assert.equal(isPlaceLine(line), true);
953+ assert.equal(canonicalIsPlaceLine(line), true);
954+ assert.equal(isPlaceLine({ ...line, extra: true }), false);
955+ assert.equal(canonicalIsPlaceLine({ ...line, extra: true }), false);
956+});
957+
902 test("the first-load gzip metric includes the eagerly imported badge resolver", () => {958 test("the first-load gzip metric includes the eagerly imported badge resolver", () => {
903 const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8");959 const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8");
904 assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u);960 assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u);
modified packages/contracts/src/index.ts +34 -6
@@ -263,11 +263,24 @@ export interface ErrorResult {
263263 occurredAt: number;
264264 retryAfterSeconds?: number;
265265 }
266+
267+/** Display metadata for one native line. Its mode is the enclosing place's mode. */
268+export interface PlaceLine {
269+ lineLabel: string;
270+ lineColor: string;
271+ lineTextColor: string;
272+}
273+
266274 export interface PlaceSearchItem {
267275 placeId: string;
268276 stopLabel: string;
269277 localityLabel?: string;
270278 mode: TransportMode;
279+ /**
280+ * All serving lines, nonempty and deduplicated by native line identity.
281+ * Natural label order, then native identity. Equal display labels remain distinct.
282+ */
283+ lines: PlaceLine[];
271284 }
272285
273286 export interface PlaceSearchResult {
@@ -367,7 +380,8 @@ const domainKeys = {
367380 trafficDetailDisrupted: ["schemaVersion", "requestId", "favoriteId", "state", "checkedAt", "sourceUpdatedAt", "title", "text", "validFrom", "validUntil"],
368381 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
369382 keyUpdate: ["schemaVersion", "action", "value"],
370- placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],
383+ placeLine: ["lineLabel", "lineColor", "lineTextColor"],
384+ placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode", "lines"],
371385 placeSearchResult: ["schemaVersion", "places"],
372386 serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"],
373387 serviceOptionsResult: ["schemaVersion", "placeId", "services"],
@@ -645,12 +659,26 @@ export function isErrorResult(value: unknown): value is ErrorResult {
645659 && optionalUint32(value.retryAfterSeconds);
646660 }
647661
662+export function isPlaceLine(value: unknown): value is PlaceLine {
663+ return object(value)
664+ && exactKeys(value, domainKeys.placeLine)
665+ && domainKeys.placeLine.every((field) => Object.hasOwn(value, field))
666+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
667+ && isLineColor(value.lineColor)
668+ && isLineColor(value.lineTextColor);
669+}
670+
648671 export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem {
649- if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false;
650- return boundedString(value.placeId, LIMITS.idUtf8Bytes)
651- && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
652- && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)
653- && TRANSPORT_MODE.includes(value.mode as TransportMode);
672+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)
673+ || !Object.hasOwn(value, "placeId") || !Object.hasOwn(value, "stopLabel")
674+ || !Object.hasOwn(value, "mode") || !Object.hasOwn(value, "lines")
675+ || !boundedString(value.placeId, LIMITS.idUtf8Bytes)
676+ || !boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
677+ || !optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)
678+ || !TRANSPORT_MODE.includes(value.mode as TransportMode)
679+ || !Array.isArray(value.lines) || value.lines.length === 0) return false;
680+ for (const line of value.lines) if (!isPlaceLine(line)) return false;
681+ return true;
654682 }
655683
656684 export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult {
@@ -263,11 +263,24 @@ export interface ErrorResult {
263 occurredAt: number;263 occurredAt: number;
264 retryAfterSeconds?: number;264 retryAfterSeconds?: number;
265 }265 }
266+
267+/** Display metadata for one native line. Its mode is the enclosing place's mode. */
268+export interface PlaceLine {
269+ lineLabel: string;
270+ lineColor: string;
271+ lineTextColor: string;
272+}
273+
266 export interface PlaceSearchItem {274 export interface PlaceSearchItem {
267 placeId: string;275 placeId: string;
268 stopLabel: string;276 stopLabel: string;
269 localityLabel?: string;277 localityLabel?: string;
270 mode: TransportMode;278 mode: TransportMode;
279+ /**
280+ * All serving lines, nonempty and deduplicated by native line identity.
281+ * Natural label order, then native identity. Equal display labels remain distinct.
282+ */
283+ lines: PlaceLine[];
271 }284 }
272 285
273 export interface PlaceSearchResult {286 export interface PlaceSearchResult {
@@ -367,7 +380,8 @@ const domainKeys = {
367 trafficDetailDisrupted: ["schemaVersion", "requestId", "favoriteId", "state", "checkedAt", "sourceUpdatedAt", "title", "text", "validFrom", "validUntil"],380 trafficDetailDisrupted: ["schemaVersion", "requestId", "favoriteId", "state", "checkedAt", "sourceUpdatedAt", "title", "text", "validFrom", "validUntil"],
368 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],381 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
369 keyUpdate: ["schemaVersion", "action", "value"],382 keyUpdate: ["schemaVersion", "action", "value"],
370- placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],383+ placeLine: ["lineLabel", "lineColor", "lineTextColor"],
384+ placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode", "lines"],
371 placeSearchResult: ["schemaVersion", "places"],385 placeSearchResult: ["schemaVersion", "places"],
372 serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"],386 serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"],
373 serviceOptionsResult: ["schemaVersion", "placeId", "services"],387 serviceOptionsResult: ["schemaVersion", "placeId", "services"],
@@ -645,12 +659,26 @@ export function isErrorResult(value: unknown): value is ErrorResult {
645 && optionalUint32(value.retryAfterSeconds);659 && optionalUint32(value.retryAfterSeconds);
646 }660 }
647 661
662+export function isPlaceLine(value: unknown): value is PlaceLine {
663+ return object(value)
664+ && exactKeys(value, domainKeys.placeLine)
665+ && domainKeys.placeLine.every((field) => Object.hasOwn(value, field))
666+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
667+ && isLineColor(value.lineColor)
668+ && isLineColor(value.lineTextColor);
669+}
670+
648 export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem {671 export function isPlaceSearchItem(value: unknown): value is PlaceSearchItem {
649- if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)) return false;672+ if (!object(value) || !exactKeys(value, domainKeys.placeSearchItem)
650- return boundedString(value.placeId, LIMITS.idUtf8Bytes)673+ || !Object.hasOwn(value, "placeId") || !Object.hasOwn(value, "stopLabel")
651- && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)674+ || !Object.hasOwn(value, "mode") || !Object.hasOwn(value, "lines")
652- && optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)675+ || !boundedString(value.placeId, LIMITS.idUtf8Bytes)
653- && TRANSPORT_MODE.includes(value.mode as TransportMode);676+ || !boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
677+ || !optionalBoundedString(value.localityLabel, LIMITS.labelUtf8Bytes)
678+ || !TRANSPORT_MODE.includes(value.mode as TransportMode)
679+ || !Array.isArray(value.lines) || value.lines.length === 0) return false;
680+ for (const line of value.lines) if (!isPlaceLine(line)) return false;
681+ return true;
654 }682 }
655 683
656 export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult {684 export function isPlaceSearchResult(value: unknown): value is PlaceSearchResult {
modified packages/contracts/test/contracts.test.ts +42 -1
@@ -37,6 +37,7 @@ import {
3737 isOverviewRequest,
3838 isOverviewResult,
3939 isPersonalApiKey,
40+ isPlaceLine,
4041 isPlaceSearchItem,
4142 isPlaceSearchResult,
4243 isServiceOption,
@@ -429,6 +430,45 @@ test("catalog contracts freeze transport modes, error codes, and limits", () =>
429430 assert.equal(LIMITS.catalogSearchResults, 20);
430431 });
431432
433+test("place lines require complete display metadata within the existing label and color bounds", () => {
434+ const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
435+ const place = { placeId: `plc_${"A".repeat(43)}`, stopLabel: "Université", mode: "METRO", lines: [line] };
436+ assert.equal(isPlaceLine(line), true);
437+ assert.equal(isPlaceSearchItem(place), true);
438+ assert.equal(isPlaceLine({ ...line, lineLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2) }), true);
439+
440+ const { lines: _lines, ...withoutLines } = place;
441+ assert.equal(isPlaceSearchItem(withoutLines), false);
442+ for (const invalid of [undefined, null, {}, line, [], [null], new Array(1)]) {
443+ assert.equal(isPlaceSearchItem({ ...place, lines: invalid }), false);
444+ }
445+ for (const field of ["lineLabel", "lineColor", "lineTextColor"] as const) {
446+ const missing: Partial<typeof line> = { ...line };
447+ delete missing[field];
448+ assert.equal(isPlaceLine(missing), false);
449+ assert.equal(isPlaceSearchItem({ ...place, lines: [missing] }), false);
450+ }
451+ for (const invalid of [
452+ { ...line, lineLabel: "" },
453+ { ...line, lineLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2 + 1) },
454+ { ...line, lineLabel: "13\n" },
455+ { ...line, lineColor: "#82C8E6" },
456+ { ...line, lineColor: "#12345" },
457+ { ...line, lineTextColor: "transparent" },
458+ { ...line, lineMode: "METRO" },
459+ { ...line, lineRef: "STIF:Line::C01313:" },
460+ { ...line, destinationLabel: "Terminus" },
461+ ]) {
462+ assert.equal(isPlaceLine(invalid), false);
463+ assert.equal(isPlaceSearchItem({ ...place, lines: [invalid] }), false);
464+ }
465+ // The response byte bound, not an arbitrary line-count limit, controls transport size.
466+ assert.equal(isPlaceSearchItem({
467+ ...place,
468+ lines: Array.from({ length: 65 }, (_, index) => ({ ...line, lineLabel: String(index) })),
469+ }), true);
470+});
471+
432472 test("catalog validators require phone routing and keep place search display-only", () => {
433473 const opaque64 = "x".repeat(LIMITS.idUtf8Bytes);
434474 const placeItem = {
@@ -436,6 +476,7 @@ test("catalog validators require phone routing and keep place search display-onl
436476 stopLabel: "Châtelet",
437477 localityLabel: "Paris",
438478 mode: "METRO",
479+ lines: [{ lineLabel: "4", lineColor: "#cf009e", lineTextColor: "#ffffff" }],
439480 };
440481 const serviceOption = {
441482 serviceId: `svc_${"B".repeat(43)}`,
@@ -447,7 +488,7 @@ test("catalog validators require phone routing and keep place search display-onl
447488 lineTextColor: "#000000",
448489 routing,
449490 };
450- const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode };
491+ const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode, lines: placeItem.lines };
451492
452493 assert.equal(isPlaceSearchItem(placeItem), true);
453494 assert.equal(isPlaceSearchItem(requiredPlace), true);
@@ -37,6 +37,7 @@ import {
37 isOverviewRequest,37 isOverviewRequest,
38 isOverviewResult,38 isOverviewResult,
39 isPersonalApiKey,39 isPersonalApiKey,
40+ isPlaceLine,
40 isPlaceSearchItem,41 isPlaceSearchItem,
41 isPlaceSearchResult,42 isPlaceSearchResult,
42 isServiceOption,43 isServiceOption,
@@ -429,6 +430,45 @@ test("catalog contracts freeze transport modes, error codes, and limits", () =>
429 assert.equal(LIMITS.catalogSearchResults, 20);430 assert.equal(LIMITS.catalogSearchResults, 20);
430 });431 });
431 432
433+test("place lines require complete display metadata within the existing label and color bounds", () => {
434+ const line = { lineLabel: "13", lineColor: "#82c8e6", lineTextColor: "#000000" };
435+ const place = { placeId: `plc_${"A".repeat(43)}`, stopLabel: "Université", mode: "METRO", lines: [line] };
436+ assert.equal(isPlaceLine(line), true);
437+ assert.equal(isPlaceSearchItem(place), true);
438+ assert.equal(isPlaceLine({ ...line, lineLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2) }), true);
439+
440+ const { lines: _lines, ...withoutLines } = place;
441+ assert.equal(isPlaceSearchItem(withoutLines), false);
442+ for (const invalid of [undefined, null, {}, line, [], [null], new Array(1)]) {
443+ assert.equal(isPlaceSearchItem({ ...place, lines: invalid }), false);
444+ }
445+ for (const field of ["lineLabel", "lineColor", "lineTextColor"] as const) {
446+ const missing: Partial<typeof line> = { ...line };
447+ delete missing[field];
448+ assert.equal(isPlaceLine(missing), false);
449+ assert.equal(isPlaceSearchItem({ ...place, lines: [missing] }), false);
450+ }
451+ for (const invalid of [
452+ { ...line, lineLabel: "" },
453+ { ...line, lineLabel: "é".repeat(LIMITS.labelUtf8Bytes / 2 + 1) },
454+ { ...line, lineLabel: "13\n" },
455+ { ...line, lineColor: "#82C8E6" },
456+ { ...line, lineColor: "#12345" },
457+ { ...line, lineTextColor: "transparent" },
458+ { ...line, lineMode: "METRO" },
459+ { ...line, lineRef: "STIF:Line::C01313:" },
460+ { ...line, destinationLabel: "Terminus" },
461+ ]) {
462+ assert.equal(isPlaceLine(invalid), false);
463+ assert.equal(isPlaceSearchItem({ ...place, lines: [invalid] }), false);
464+ }
465+ // The response byte bound, not an arbitrary line-count limit, controls transport size.
466+ assert.equal(isPlaceSearchItem({
467+ ...place,
468+ lines: Array.from({ length: 65 }, (_, index) => ({ ...line, lineLabel: String(index) })),
469+ }), true);
470+});
471+
432 test("catalog validators require phone routing and keep place search display-only", () => {472 test("catalog validators require phone routing and keep place search display-only", () => {
433 const opaque64 = "x".repeat(LIMITS.idUtf8Bytes);473 const opaque64 = "x".repeat(LIMITS.idUtf8Bytes);
434 const placeItem = {474 const placeItem = {
@@ -436,6 +476,7 @@ test("catalog validators require phone routing and keep place search display-onl
436 stopLabel: "Châtelet",476 stopLabel: "Châtelet",
437 localityLabel: "Paris",477 localityLabel: "Paris",
438 mode: "METRO",478 mode: "METRO",
479+ lines: [{ lineLabel: "4", lineColor: "#cf009e", lineTextColor: "#ffffff" }],
439 };480 };
440 const serviceOption = {481 const serviceOption = {
441 serviceId: `svc_${"B".repeat(43)}`,482 serviceId: `svc_${"B".repeat(43)}`,
@@ -447,7 +488,7 @@ test("catalog validators require phone routing and keep place search display-onl
447 lineTextColor: "#000000",488 lineTextColor: "#000000",
448 routing,489 routing,
449 };490 };
450- const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode };491+ const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode, lines: placeItem.lines };
451 492
452 assert.equal(isPlaceSearchItem(placeItem), true);493 assert.equal(isPlaceSearchItem(placeItem), true);
453 assert.equal(isPlaceSearchItem(requiredPlace), true);494 assert.equal(isPlaceSearchItem(requiredPlace), true);