feat(companion): modernize settings and introduce app branding (#19)Unverified
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 | + | |
| 1 | 5 | <h1 align="center">Lapin Futé</h1> |
| 2 | 6 | |
| 3 | 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 | 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 | 11 | </p> |
| 8 | 12 | |
| 9 | 13 | <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> | |
| 17 | 20 | </p> |
| 18 | 21 | |
| 19 | 22 | <p align="center"> |
| 20 | 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"> | |
| 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"> | |
| 23 | 26 | <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status"> |
| 24 | 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 | 32 | ## 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 | -```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 | |
| 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 | -## Highlights | |
| 48 | +## 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. | |
| 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 | | |
| 49 | 56 | |
| 50 | -## Architecture | |
| 57 | +## Getting started | |
| 51 | 58 | |
| 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: | |
| 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 requirements | |
| 68 | +## 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 | | |
| 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. | |
| 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 | 83 | ## 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 | 87 | ```sh |
| 78 | 88 | npm test |
| 79 | -npm run measure:radio | |
| 80 | 89 | 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 | 94 | ```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 | |
| 88 | 96 | ``` |
| 89 | 97 | |
| 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`: | |
| 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 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. | |
| 101 | 106 | |
| 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 | |
| 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 | 117 | ## 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. | |
| @@ -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 | ## About | 32 | ## 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 | -```text | 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. |
| 31 | -Departure and traffic views | 37 | + |
| 32 | - │ symbolic AppMessage aliases | 38 | +## 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 | -## Highlights | 48 | +## 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 | -## Architecture | 57 | +## 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 requirements | 68 | +## 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 source | 83 | ## 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 | ```sh | 87 | ```sh |
| 78 | npm test | 88 | npm test |
| 79 | -npm run measure:radio | ||
| 80 | npm run build | 89 | 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 | ```sh | 94 | ```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 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. |
| 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 | ## Contributing | 117 | ## 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"; | ||
| 2 | 2 | import { |
| 3 | 3 | LIMITS, |
| 4 | 4 | TRANSPORT_MODE, |
| 5 | + isPlaceLine, | |
| 6 | + type PlaceLine, | |
| 5 | 7 | type PlaceSearchItem, |
| 6 | 8 | type ServiceOption, |
| 7 | 9 | type TransportMode, |
| @@ -30,6 +32,7 @@ interface PlaceRow { | ||
| 30 | 32 | readonly stopLabel: string; |
| 31 | 33 | readonly localityLabel: string | null; |
| 32 | 34 | readonly mode: TransportMode; |
| 35 | + readonly lines: string; | |
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | interface ServiceRow { |
| @@ -446,11 +449,40 @@ function normalizedMatchQuery(query: string): string { | ||
| 446 | 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 | 480 | function placeItem(entry: PlaceRow): PlaceSearchItem { |
| 450 | 481 | const item: PlaceSearchItem = { |
| 451 | 482 | placeId: entry.placeId, |
| 452 | 483 | stopLabel: entry.stopLabel, |
| 453 | 484 | mode: entry.mode, |
| 485 | + lines: placeLinesFromJson(entry.lines), | |
| 454 | 486 | }; |
| 455 | 487 | if (entry.localityLabel !== null) item.localityLabel = entry.localityLabel; |
| 456 | 488 | return item; |
| @@ -467,19 +499,25 @@ export class SqliteCatalogReader implements CatalogReader { | ||
| 467 | 499 | private constructor(database: DatabaseSync) { |
| 468 | 500 | this.#database = database; |
| 469 | 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 | 511 | 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 | |
| 483 | 521 | `); |
| 484 | 522 | this.#placeExistsStatement = database.prepare(` |
| 485 | 523 | 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 | SELECT | 511 | 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 mode | 515 | + matches.mode AS mode, |
| 475 | - FROM place_search | 516 | + ${PLACE_LINES_SQL} AS lines |
| 476 | - JOIN places ON places.place_id = place_search.place_id | 517 | + FROM matches |
| 477 | - WHERE place_search MATCH ? | 518 | + JOIN services ON services.place_id = matches.place_id |
| 478 | - ORDER BY | 519 | + 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 1 | 523 | SELECT 1 |
modified
packages/catalog/src/static-catalog.ts +11 -4 | @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | validateCatalogCandidate, |
| 29 | 29 | type CatalogSourceAttribution, |
| 30 | 30 | } from "./catalog-import.ts"; |
| 31 | +import { PLACE_LINES_SQL, placeLinesFromJson } from "./catalog.ts"; | |
| 31 | 32 | import { catalogSearchBucket, normalizeCatalogSearchText } from "../../config-page/src/search-text.js"; |
| 32 | 33 | |
| 33 | 34 | const PLACE_ID = /^plc_[A-Za-z0-9_-]{43}$/u; |
| @@ -72,8 +73,11 @@ interface PageWriter { | ||
| 72 | 73 | const PLACE_QUERY = ` |
| 73 | 74 | SELECT places.place_id AS placeId, places.stop_label AS stopLabel, |
| 74 | 75 | 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 | |
| 77 | 81 | ORDER BY places.place_id |
| 78 | 82 | `; |
| 79 | 83 | const SERVICE_QUERY = ` |
| @@ -115,11 +119,13 @@ function publicAttribution(sources: readonly CatalogSourceAttribution[]): Catalo | ||
| 115 | 119 | } |
| 116 | 120 | |
| 117 | 121 | function searchRow(row: SqlRow): SearchRow { |
| 122 | + if (typeof row.lines !== "string") fail("invalid search row"); | |
| 118 | 123 | const place = { |
| 119 | 124 | placeId: row.placeId, |
| 120 | 125 | stopLabel: row.stopLabel, |
| 121 | 126 | ...(row.localityLabel === null ? {} : { localityLabel: row.localityLabel }), |
| 122 | 127 | mode: row.mode, |
| 128 | + lines: placeLinesFromJson(row.lines), | |
| 123 | 129 | }; |
| 124 | 130 | if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId) |
| 125 | 131 | || typeof row.searchText !== "string" || row.searchText === "" |
| @@ -209,9 +215,10 @@ export function publishStaticCatalog(options: PublishStaticCatalogOptions): Stat | ||
| 209 | 215 | } |
| 210 | 216 | // Hash the same pinned SQLite snapshot that will be exported, in bounded rows. |
| 211 | 217 | 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"); | |
| 214 | 220 | } |
| 221 | + for (const row of database.prepare(SERVICE_QUERY).iterate()) digest.update(JSON.stringify(row)).update("\n"); | |
| 215 | 222 | const revision = digest.digest("hex"); |
| 216 | 223 | const manifest: StaticCatalogManifest = { |
| 217 | 224 | 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 searchText | 76 | + place_search.search_text AS searchText, ${PLACE_LINES_SQL} AS lines |
| 76 | - FROM places JOIN place_search ON place_search.place_id = places.place_id | 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 | ||
| 77 | ORDER BY places.place_id | 81 | 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"; | ||
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 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 | 9 | import { CatalogManager, SqliteCatalogReader } from "../src/catalog.ts"; |
| 10 | 10 | |
| 11 | 11 | interface PlaceSeed { |
| @@ -315,8 +315,8 @@ test("search is accent tolerant, punctuation safe, and bounded by Unicode code p | ||
| 315 | 315 | assert.throws(() => reader.searchPlaces("a"), RangeError); |
| 316 | 316 | assert.throws(() => reader.searchPlaces("a".repeat(101)), RangeError); |
| 317 | 317 | 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)), []); | |
| 320 | 320 | }); |
| 321 | 321 | |
| 322 | 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 | 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 | 446 | key: `bus-option-${index}`, |
| 379 | 447 | placeKey: "bus", |
| 380 | 448 | mode: "BUS", |
| @@ -391,6 +459,9 @@ test("service options return every valid direction without applying the place-se | ||
| 391 | 459 | |
| 392 | 460 | const options = reader.listServices(catalog.placeIds.bus); |
| 393 | 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 | 467 | 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 | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 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 | 8 | import { buildCatalogCandidateFromRecords, createPlaceIdentity, createServiceIdentity } from "../src/catalog-import.ts"; |
| 9 | +import { SqliteCatalogReader } from "../src/catalog.ts"; | |
| 9 | 10 | import { publishStaticCatalog } from "../src/static-catalog.ts"; |
| 10 | 11 | import { createCatalogClient } from "../../config-page/src/catalog-client.js"; |
| 11 | 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 | 59 | const options = await catalogFixture(t); |
| 59 | 60 | const publication = publishStaticCatalog(options); |
| 60 | 61 | const client = pageClient(options.outputDirectory); |
| 62 | + const reader = SqliteCatalogReader.open(options.catalogPath); | |
| 61 | 63 | const selections = ["gare du nord", "chatelet", "porte de versailles", "chatelet les halles", "saint lazare"]; |
| 62 | 64 | const modes = new Set(); |
| 63 | 65 | for (const query of selections) { |
| 64 | 66 | for (const place of await client.searchPlaces(query)) { |
| 65 | 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 | 78 | assert.equal(service.lineMode, place.mode); |
| 68 | 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 | 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 | 179 | test("new publication removes a stale service only for new sessions and keeps open sessions on their revision", async (t) => { |
| 92 | 180 | const first = await catalogFixture(t); |
| 93 | 181 | const oldPublication = publishStaticCatalog(first); |
| @@ -129,9 +217,10 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | ||
| 129 | 217 | insertSearch.run("busy fixture", busy.placeId); |
| 130 | 218 | for (let index = 0; index < 12; index += 1) { |
| 131 | 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 | 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 | 225 | database.exec("COMMIT"); |
| 137 | 226 | } finally { |
| @@ -147,6 +236,14 @@ test("publisher splits search and busy-place pages by decoded bytes without drop | ||
| 147 | 236 | const client = pageClient(options.outputDirectory, requests); |
| 148 | 237 | const matches = await client.searchPlaces("café"); |
| 149 | 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 | 247 | const services = await client.listServices(busy.placeId); |
| 151 | 248 | assert.deepEqual(services.map((service) => service.lineLabel), Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0"))); |
| 152 | 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 | 251 | assert.equal(requests.some((url) => url.includes(`/places/${busy.placeId}/1.json`)), true); |
| 155 | 252 | assert.deepEqual((await client.searchPlaces("C.")).map((place) => place.placeId), matches.map((place) => place.placeId)); |
| 156 | 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 | 262 | let fileCount = 0; |
| 158 | 263 | let totalBytes = 0; |
| 159 | 264 | 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 differ | Binary 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 differ | Binary 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 @@ | ||
| 7 | 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 | 8 | <title>Lapin Futé</title> |
| 9 | 9 | <link rel="stylesheet" href="./styles/config-page.css"> |
| 10 | + <link rel="icon" type="image/svg+xml" href="./assets/lapin-fute.svg"> | |
| 10 | 11 | </head> |
| 11 | 12 | <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> | |
| 17 | 17 | </div> |
| 18 | 18 | </header> |
| 19 | 19 | |
| 20 | 20 | <main> |
| 21 | 21 | <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> | |
| 33 | 56 | </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> | |
| 38 | 58 | |
| 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> | |
| 43 | 74 | </div> |
| 44 | - <p class="status" id="favorites-empty" hidden></p> | |
| 45 | - <ol id="favorites-list" class="favorites"></ol> | |
| 46 | - </section> | |
| 75 | + </details> | |
| 47 | 76 | |
| 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> | |
| 58 | 97 | |
| 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> | |
| 63 | 102 | |
| 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> | |
| 72 | 117 | </div> |
| 73 | - </section> | |
| 118 | + </details> | |
| 74 | 119 | |
| 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> | |
| 79 | 137 | </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> | |
| 86 | 140 | </div> |
| 87 | 141 | |
| 88 | 142 | <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"> | |
| 92 | 145 | <h2 id="about-title"></h2> |
| 93 | 146 | </div> |
| 94 | 147 | |
| @@ -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) { | ||
| 71 | 71 | export const COPY = { |
| 72 | 72 | en: { |
| 73 | 73 | pageTitle: "Lapin Futé settings", |
| 74 | + pageHeading: "Settings", | |
| 74 | 75 | intro: "Choose the departures you want at hand on your Pebble.", |
| 75 | 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 | 78 | keyStatusMissing: "No key is configured.", |
| 78 | 79 | keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.", |
| 79 | 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 | 84 | keyLabel: "Replace with a new key", |
| 81 | 85 | keyPlaceholder: "Paste a new key", |
| 82 | 86 | keyShow: "Show", |
| @@ -88,8 +92,13 @@ export const COPY = { | ||
| 88 | 92 | keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.", |
| 89 | 93 | keyErrorNewline: "The key must not contain control characters.", |
| 90 | 94 | favoritesTitle: "Favorite departures", |
| 95 | + favoriteUsed: "used", | |
| 96 | + favoritesUsed: "used", | |
| 97 | + favoriteAvailable: "available", | |
| 98 | + favoritesAvailable: "available", | |
| 91 | 99 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 92 | 100 | favoriteAddTitle: "Add a favorite", |
| 101 | + favoriteAddCaption: "Stop, line and direction", | |
| 93 | 102 | searchLabel: "Stop or station", |
| 94 | 103 | searchPlaceholder: "Search by name", |
| 95 | 104 | searchHint: "Enter at least 2 characters.", |
| @@ -118,15 +127,20 @@ export const COPY = { | ||
| 118 | 127 | aboutBack: "Back to settings", |
| 119 | 128 | aboutTitle: "About Lapin Futé", |
| 120 | 129 | syncTitle: "Watch synchronization", |
| 130 | + syncCaption: "Transfer options", | |
| 121 | 131 | syncHint: "The watch normally receives only what changed. Tick this to resend everything if the watch shows outdated favorites.", |
| 122 | 132 | forceSyncLabel: "Force a full synchronization at the next save", |
| 123 | 133 | saveTooLarge: "These settings are too large to transfer. Remove the favorite with the longest entry and add it again.", |
| 124 | 134 | }, |
| 125 | 135 | fr: { |
| 126 | 136 | pageTitle: "Réglages Lapin Futé", |
| 137 | + pageHeading: "Réglages", | |
| 127 | 138 | intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.", |
| 128 | 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 | 144 | keyStatusMissing: "Aucune clé n’est configurée.", |
| 131 | 145 | keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.", |
| 132 | 146 | keyLink: "Créer un jeton PRIM", |
| @@ -141,8 +155,13 @@ export const COPY = { | ||
| 141 | 155 | keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.", |
| 142 | 156 | keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.", |
| 143 | 157 | favoritesTitle: "Départs favoris", |
| 158 | + favoriteUsed: "utilisé", | |
| 159 | + favoritesUsed: "utilisés", | |
| 160 | + favoriteAvailable: "disponible", | |
| 161 | + favoritesAvailable: "disponibles", | |
| 144 | 162 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 145 | 163 | favoriteAddTitle: "Ajouter un favori", |
| 164 | + favoriteAddCaption: "Arrêt, ligne et direction", | |
| 146 | 165 | searchLabel: "Arrêt ou gare", |
| 147 | 166 | searchPlaceholder: "Rechercher par nom", |
| 148 | 167 | searchHint: "Saisissez au moins 2 caractères.", |
| @@ -171,6 +190,7 @@ export const COPY = { | ||
| 171 | 190 | aboutBack: "Retour aux réglages", |
| 172 | 191 | aboutTitle: "À propos de Lapin Futé", |
| 173 | 192 | syncTitle: "Synchronisation de la montre", |
| 193 | + syncCaption: "Options de transfert", | |
| 174 | 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 | 195 | forceSyncLabel: "Forcer une synchronisation complète au prochain enregistrement", |
| 176 | 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 | 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 | 263 | const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"]; |
| 243 | 264 | const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]; |
| 244 | 265 | |
| @@ -248,12 +269,32 @@ function exactFields(value, allowed, required) { | ||
| 248 | 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 | 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 | 293 | && boundedString(value.placeId, LIMITS.idUtf8Bytes) |
| 254 | 294 | && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) |
| 255 | 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 | 300 | 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) { | ||
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | 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", | |
| 33 | 35 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 34 | 36 | |
| 35 | 37 | const opening = parseConfigFragment(window.location.hash); |
| @@ -53,9 +55,13 @@ function setText(element, value) { | ||
| 53 | 55 | |
| 54 | 56 | const NEUTRAL_LINE_BACKGROUND = "#52616f"; |
| 55 | 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 | 63 | function fallbackLineBadge(service) { |
| 58 | - const namespace = "http://www.w3.org/2000/svg"; | |
| 64 | + const namespace = SVG_NAMESPACE; | |
| 59 | 65 | const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12)); |
| 60 | 66 | const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND; |
| 61 | 67 | const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT; |
| @@ -113,16 +119,20 @@ function applyCopy() { | ||
| 113 | 119 | document.title = copy.pageTitle; |
| 114 | 120 | document.documentElement.lang = opening.locale; |
| 115 | 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 | 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 | 126 | ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title], |
| 119 | 127 | ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title], |
| 128 | + ["favoriteAddCaption", elements.add_caption], | |
| 120 | 129 | ["searchLabel", elements.search_label], ["searchHint", elements.search_hint], |
| 121 | 130 | ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title], |
| 122 | 131 | ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add], |
| 123 | 132 | ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back], |
| 124 | 133 | ["aboutTitle", elements.about_title], |
| 125 | 134 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], |
| 135 | + ["syncCaption", elements.sync_caption], | |
| 126 | 136 | ["forceSyncLabel", elements.force_sync_label], |
| 127 | 137 | ]) setText(element, copy[key]); |
| 128 | 138 | elements.key_input.placeholder = copy.keyPlaceholder; |
| @@ -134,6 +144,8 @@ function applyCopy() { | ||
| 134 | 144 | |
| 135 | 145 | function renderKey() { |
| 136 | 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 | 149 | const removing = state.keyDraft.removeRequested; |
| 138 | 150 | const replacing = state.keyDraft.value.length > 0; |
| 139 | 151 | elements.key_pending.hidden = !removing && !replacing; |
| @@ -154,12 +166,69 @@ function actionButton(label, disabled, action, className = "quiet") { | ||
| 154 | 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 | 221 | function renderFavorites() { |
| 158 | 222 | elements.favorites_list.replaceChildren(); |
| 159 | 223 | 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}`); | |
| 163 | 232 | |
| 164 | 233 | state.favorites.forEach((favorite, index) => { |
| 165 | 234 | const item = document.createElement("li"); |
| @@ -168,17 +237,25 @@ function renderFavorites() { | ||
| 168 | 237 | renderLineBadge(route, favorite); |
| 169 | 238 | const labels = document.createElement("div"); |
| 170 | 239 | labels.className = "favorite-labels"; |
| 240 | + const journey = document.createElement("div"); | |
| 241 | + journey.className = "route-journey"; | |
| 171 | 242 | const stop = document.createElement("strong"); |
| 243 | + stop.className = "route-stop"; | |
| 172 | 244 | stop.textContent = favorite.stopLabel; |
| 173 | 245 | const destination = document.createElement("span"); |
| 246 | + destination.className = "route-destination"; | |
| 174 | 247 | destination.textContent = favorite.destinationLabel; |
| 175 | - labels.append(stop, destination); | |
| 248 | + journey.append(stop, destination); | |
| 249 | + labels.append(journey); | |
| 176 | 250 | if (!Object.hasOwn(favorite, "routing")) { |
| 177 | 251 | const unresolved = document.createElement("span"); |
| 178 | 252 | unresolved.className = "unresolved"; |
| 179 | 253 | unresolved.textContent = copy.favoriteUnresolved; |
| 180 | 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 | 260 | const renameLabel = document.createElement("label"); |
| 184 | 261 | renameLabel.className = "visually-hidden"; |
| @@ -187,6 +264,7 @@ function renderFavorites() { | ||
| 187 | 264 | renameLabel.textContent = copy.favoriteRename; |
| 188 | 265 | const rename = document.createElement("input"); |
| 189 | 266 | rename.id = renameId; |
| 267 | + rename.className = "favorite-name"; | |
| 190 | 268 | rename.type = "text"; |
| 191 | 269 | rename.autocomplete = "off"; |
| 192 | 270 | rename.maxLength = LIMITS.labelUtf8Bytes; |
| @@ -194,23 +272,58 @@ function renderFavorites() { | ||
| 194 | 272 | rename.placeholder = copy.favoriteNamePlaceholder; |
| 195 | 273 | rename.addEventListener("change", () => { |
| 196 | 274 | 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 }); | |
| 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 | 293 | const actions = document.createElement("div"); |
| 205 | 294 | 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); | |
| 212 | 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 | 329 | function clearServiceSelection() { |
| @@ -228,10 +341,27 @@ function renderPlaces() { | ||
| 228 | 341 | elements.place_results.replaceChildren(); |
| 229 | 342 | for (const place of places) { |
| 230 | 343 | 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); | |
| 235 | 365 | elements.place_results.append(item); |
| 236 | 366 | } |
| 237 | 367 | } |
| @@ -247,6 +377,7 @@ async function selectPlace(place) { | ||
| 247 | 377 | if (controller.signal.aborted) return; |
| 248 | 378 | serviceController = null; |
| 249 | 379 | if (services.length === 0) { |
| 380 | + revealSection(elements.add_section); | |
| 250 | 381 | setText(elements.catalog_status, copy.servicesEmpty); |
| 251 | 382 | return; |
| 252 | 383 | } |
| @@ -262,11 +393,13 @@ async function selectPlace(place) { | ||
| 262 | 393 | elements.service_select.append(option); |
| 263 | 394 | }); |
| 264 | 395 | elements.service_step.hidden = false; |
| 396 | + revealSection(elements.add_section); | |
| 265 | 397 | setText(elements.catalog_status, ""); |
| 266 | 398 | elements.service_select.focus(); |
| 267 | 399 | } catch (error) { |
| 268 | 400 | if (controller.signal.aborted) return; |
| 269 | 401 | serviceController = null; |
| 402 | + revealSection(elements.add_section); | |
| 270 | 403 | setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE" |
| 271 | 404 | ? copy.invalidService |
| 272 | 405 | : copy.backendUnavailable); |
| @@ -360,9 +493,15 @@ elements.place_search.addEventListener("input", async () => { | ||
| 360 | 493 | if (generation !== searchGeneration) return; |
| 361 | 494 | places = result; |
| 362 | 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 | 502 | } catch (error) { |
| 365 | 503 | if (error?.name === "AbortError" || generation !== searchGeneration) return; |
| 504 | + revealSection(elements.add_section); | |
| 366 | 505 | setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY" |
| 367 | 506 | ? copy.searchHint |
| 368 | 507 | : copy.backendUnavailable); |
| @@ -423,6 +562,7 @@ elements.force_sync.addEventListener("change", () => { | ||
| 423 | 562 | elements.save.addEventListener("click", () => { |
| 424 | 563 | const keyError = apiKeyError(state.keyDraft.value); |
| 425 | 564 | if (keyError !== null) { |
| 565 | + revealSection(elements.prim_section); | |
| 426 | 566 | setText(elements.key_error, copy[keyError]); |
| 427 | 567 | elements.key_error.hidden = false; |
| 428 | 568 | elements.key_input.focus(); |
| @@ -430,11 +570,20 @@ elements.save.addEventListener("click", () => { | ||
| 430 | 570 | } |
| 431 | 571 | const outcome = planConfigResult(state); |
| 432 | 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 | 580 | return; |
| 435 | 581 | } |
| 436 | 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 | 587 | return; |
| 439 | 588 | } |
| 440 | 589 | const closeUrl = closeSession.close(outcome.payload); |
| @@ -443,6 +592,56 @@ elements.save.addEventListener("click", () => { | ||
| 443 | 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 | 645 | applyCopy(); |
| 447 | 646 | renderKey(); |
| 448 | 647 | 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.localityLabel | 344 | + 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.invalidService | 404 | ? 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.searchHint | 506 | ? 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 | + */ | |
| 1 | 5 | :root { |
| 2 | 6 | 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; | |
| 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 | 48 | html:not(.ready) body { visibility: hidden; } |
| 18 | - | |
| 19 | 49 | body { |
| 20 | - margin: 0; | |
| 50 | + display: flex; | |
| 51 | + flex-direction: column; | |
| 21 | 52 | 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); | |
| 24 | 59 | font-size: 1rem; |
| 25 | - line-height: 1.45; | |
| 60 | + line-height: 1.5; | |
| 26 | 61 | } |
| 27 | - | |
| 28 | 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 | 70 | display: flex; |
| 33 | - gap: 0.875rem; | |
| 34 | 71 | 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); | |
| 39 | 76 | } |
| 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; | |
| 51 | 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 { | |
| 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; | |
| 57 | 112 | 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); | |
| 62 | 118 | } |
| 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 { | |
| 65 | 123 | 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; | |
| 87 | 129 | } |
| 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; | |
| 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 | 152 | input, select { |
| 110 | 153 | 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; | |
| 117 | 162 | } |
| 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; | |
| 122 | 167 | } |
| 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; } | |
| 130 | 175 | |
| 131 | 176 | button { |
| 177 | + min-width: 0; | |
| 132 | 178 | min-height: 2.75rem; |
| 133 | - padding: 0.55rem 0.8rem; | |
| 179 | + padding: 0.625rem 0.875rem; | |
| 134 | 180 | border: 1px solid transparent; |
| 135 | - border-radius: 0.35rem; | |
| 181 | + border-radius: 0.625rem; | |
| 136 | 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; } | |
| 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); } | |
| 143 | 191 | |
| 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; } | |
| 151 | 206 | |
| 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; } | |
| 172 | 231 | |
| 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; } | |
| 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 | 287 | [hidden] { display: none !important; } |
| 242 | 288 | |
| 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; } | |
| 247 | 314 | } |
| 248 | 315 | |
| 249 | 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 | } |
| @@ -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 = { | ||
| 14 | 14 | function place(index, stopLabel = "Châtelet", localityLabel = "Paris") { |
| 15 | 15 | return { |
| 16 | 16 | placeId: `plc_${String(index).padStart(43, "0")}`, stopLabel, localityLabel, mode: "BUS", |
| 17 | + lines: [{ lineLabel: "21", lineColor: "#0064b0", lineTextColor: "#ffffff" }], | |
| 17 | 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 | 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 | 112 | test("static search uses AND token prefixes, Unicode normalization and one-codepoint buckets", async () => { |
| 71 | 113 | const match = place(1, "Rue Saint-Denis Châtelet"); |
| 72 | 114 | 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 { | ||
| 11 | 11 | isApiKeyUpdate, |
| 12 | 12 | isFavorite, |
| 13 | 13 | isPersonalApiKey, |
| 14 | + isPlaceLine as canonicalIsPlaceLine, | |
| 15 | + isPlaceSearchItem as canonicalIsPlaceSearchItem, | |
| 14 | 16 | type Favorite, |
| 15 | 17 | } from "../../contracts/src/index.ts"; |
| 16 | 18 | import { |
| @@ -29,6 +31,8 @@ import { | ||
| 29 | 31 | initialConfigState, |
| 30 | 32 | isFavoriteShape, |
| 31 | 33 | isPhoneFavorite, |
| 34 | + isPlaceLine, | |
| 35 | + isPlaceSearchItem, | |
| 32 | 36 | isPlaceSearchResult, |
| 33 | 37 | isServiceOptionsResult, |
| 34 | 38 | isServiceRouting, |
| @@ -536,11 +540,18 @@ test("three backend services can be added, renamed, reordered, and removed atomi | ||
| 536 | 540 | }); |
| 537 | 541 | |
| 538 | 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 | 545 | const service = serviceOption("svc-a"); |
| 541 | 546 | assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true); |
| 542 | 547 | assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false); |
| 543 | 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 | 555 | assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true); |
| 545 | 556 | assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false); |
| 546 | 557 | const { routing: _routing, ...routingLess } = service; |
| @@ -830,9 +841,12 @@ test("page keeps secrets out of durable and observable surfaces", () => { | ||
| 830 | 841 | const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); |
| 831 | 842 | const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8"); |
| 832 | 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 | 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 | 850 | assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u); |
| 837 | 851 | assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u); |
| 838 | 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 | 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 | 958 | test("the first-load gzip metric includes the eagerly imported badge resolver", () => { |
| 903 | 959 | const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8"); |
| 904 | 960 | 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 { | ||
| 263 | 263 | occurredAt: number; |
| 264 | 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 | 274 | export interface PlaceSearchItem { |
| 267 | 275 | placeId: string; |
| 268 | 276 | stopLabel: string; |
| 269 | 277 | localityLabel?: string; |
| 270 | 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 | 286 | export interface PlaceSearchResult { |
| @@ -367,7 +380,8 @@ const domainKeys = { | ||
| 367 | 380 | trafficDetailDisrupted: ["schemaVersion", "requestId", "favoriteId", "state", "checkedAt", "sourceUpdatedAt", "title", "text", "validFrom", "validUntil"], |
| 368 | 381 | error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"], |
| 369 | 382 | keyUpdate: ["schemaVersion", "action", "value"], |
| 370 | - placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"], | |
| 383 | + placeLine: ["lineLabel", "lineColor", "lineTextColor"], | |
| 384 | + placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode", "lines"], | |
| 371 | 385 | placeSearchResult: ["schemaVersion", "places"], |
| 372 | 386 | serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "routing"], |
| 373 | 387 | serviceOptionsResult: ["schemaVersion", "placeId", "services"], |
| @@ -645,12 +659,26 @@ export function isErrorResult(value: unknown): value is ErrorResult { | ||
| 645 | 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 | 671 | 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; | |
| 654 | 682 | } |
| 655 | 683 | |
| 656 | 684 | 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 { | ||
| 37 | 37 | isOverviewRequest, |
| 38 | 38 | isOverviewResult, |
| 39 | 39 | isPersonalApiKey, |
| 40 | + isPlaceLine, | |
| 40 | 41 | isPlaceSearchItem, |
| 41 | 42 | isPlaceSearchResult, |
| 42 | 43 | isServiceOption, |
| @@ -429,6 +430,45 @@ test("catalog contracts freeze transport modes, error codes, and limits", () => | ||
| 429 | 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 | 472 | test("catalog validators require phone routing and keep place search display-only", () => { |
| 433 | 473 | const opaque64 = "x".repeat(LIMITS.idUtf8Bytes); |
| 434 | 474 | const placeItem = { |
| @@ -436,6 +476,7 @@ test("catalog validators require phone routing and keep place search display-onl | ||
| 436 | 476 | stopLabel: "Châtelet", |
| 437 | 477 | localityLabel: "Paris", |
| 438 | 478 | mode: "METRO", |
| 479 | + lines: [{ lineLabel: "4", lineColor: "#cf009e", lineTextColor: "#ffffff" }], | |
| 439 | 480 | }; |
| 440 | 481 | const serviceOption = { |
| 441 | 482 | serviceId: `svc_${"B".repeat(43)}`, |
| @@ -447,7 +488,7 @@ test("catalog validators require phone routing and keep place search display-onl | ||
| 447 | 488 | lineTextColor: "#000000", |
| 448 | 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 | 493 | assert.equal(isPlaceSearchItem(placeItem), true); |
| 453 | 494 | 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); |