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

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

Download ZIP

feat: migrate to phone-only data and phone-prepared watch displays #17

Merged@github-anthodev wants to merge feature/redesign-watch-experience/watch/16 into develop
Merged as a46bf24ac09c.
100 files changed
added .env.example +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+# Lapin Futé operator environment (phone-only architecture: no backend server,
2+# no server deployment variables). Copy to .env for local operations:
3+# node --env-file=.env npm run catalog:refresh
4+# Secrets live only in .env (gitignored); scripts never print or export them.
5+
6+# Required by scripts/refresh-catalog.mjs: IDFM dataset token used to download
7+# and re-verify the GTFS/catalog source datasets.
8+IDFM_DATASET_TOKEN=
9+
10+# Required by scripts/probe-catalog.mjs: personal PRIM key for the one-call
11+# live stop-monitoring probe (one request per transport mode at most).
12+PRIM_API_KEY=
13+
14+# Optional paths (defaults shown):
15+# CATALOG_PATH=var/catalog/catalog.sqlite
16+# CATALOG_STATIC_PATH=var/catalog/static
17+
18+# Optional, scripts/build.mjs: absolute HTTPS URL of the statically hosted
19+# configuration site, baked into the phone companion bootstrap.
20+# LAPIN_FUTE_CONFIG_URL=
new file mode 100644
@@ -0,0 +1,20 @@
1+# Lapin Futé operator environment (phone-only architecture: no backend server,
2+# no server deployment variables). Copy to .env for local operations:
3+# node --env-file=.env npm run catalog:refresh
4+# Secrets live only in .env (gitignored); scripts never print or export them.
5+
6+# Required by scripts/refresh-catalog.mjs: IDFM dataset token used to download
7+# and re-verify the GTFS/catalog source datasets.
8+IDFM_DATASET_TOKEN=
9+
10+# Required by scripts/probe-catalog.mjs: personal PRIM key for the one-call
11+# live stop-monitoring probe (one request per transport mode at most).
12+PRIM_API_KEY=
13+
14+# Optional paths (defaults shown):
15+# CATALOG_PATH=var/catalog/catalog.sqlite
16+# CATALOG_STATIC_PATH=var/catalog/static
17+
18+# Optional, scripts/build.mjs: absolute HTTPS URL of the statically hosted
19+# configuration site, baked into the phone companion bootstrap.
20+# LAPIN_FUTE_CONFIG_URL=
modified .gitignore +1 -0
@@ -5,6 +5,7 @@ node_modules/
55 var/
66 packages/watch/build/
77 packages/watch/src/pkjs/
8+packages/watch/src/generated/
89 docs/
910 *.pbw
1011 .DS_Store
@@ -5,6 +5,7 @@ node_modules/
5 var/5 var/
6 packages/watch/build/6 packages/watch/build/
7 packages/watch/src/pkjs/7 packages/watch/src/pkjs/
8+packages/watch/src/generated/
8 docs/9 docs/
9 *.pbw10 *.pbw
10 .DS_Store11 .DS_Store
modified README.md +30 -42
@@ -20,46 +20,43 @@
2020 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
2121 <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript">
2222 <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">
23- <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status">
23+ <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">
2424 </p>
2525
26-> [!IMPORTANT]
27-> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host.
28-
2926 ## About
3027
3128 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.
3229
3330 ```text
34-Minimal Piu fixture view
31+Departure and traffic views
3532 │ symbolic AppMessage aliases
3633 ▼
37-PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM
34+PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM
3835 │
39- └── phone-local favorites + personal key
36+ └── phone-local favorites (six) + personal key
4037 ```
4138
42-**The watch never receives the personal PRIM key and never starts network or background work.**
39+**The watch never receives the personal PRIM key and never starts network or background requests.**
4340
4441 ## Highlights
4542
46-- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap.
47-- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`.
48-- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state.
49-- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR.
50-- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two.
51-- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary.
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.
5249
5350 ## Architecture
5451
5552 | Component | Owns | Must not |
5653 |---|---|---|
57-| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences |
58-| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening |
59-| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage |
60-| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage |
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 |
6158
62-The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`.
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.
6360
6461 ## Targets and requirements
6562
@@ -70,14 +67,12 @@ The embedded modules are `main`, `controller`, `model`, `protocol`, `message-que
7067 | Pebble SDK | 4.33.1 |
7168 | Pebble CLI | `pebble-tool` 5.0.40 |
7269 | Host runtime | Node.js 24.18.0 |
73-| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media |
74-
75-See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands.
70+| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media |
7671
7772
7873 ## Build from source
7974
80-Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain.
75+Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain.
8176
8277 ```sh
8378 npm test
@@ -85,19 +80,20 @@ npm run measure:radio
8580 npm run build
8681 ```
8782
88-`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs:
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:
8984
9085 ```sh
9186 LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \
92-LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \
9387 npm run build
9488 ```
9589
9690 Personal PRIM keys are never build inputs.
9791
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+
9894 ## Privacy
9995
100-The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.
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.
10197
10298 Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.
10399
@@ -105,26 +101,18 @@ Lapin Futé has no telemetry. The watch has no network module and starts no peri
105101
106102 | Gate | Budget |
107103 |---|---|
108-| Alloy app binary | At most 120 KiB on each target |
109-| Loaded image and RAM | At most 56 KiB each |
110-| XS arenas | At least 25% free at the worst measured point |
111-| Settled XS drift | At most 1 KiB over the final ten of 50 cycles |
112-| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes |
113-| Watch request | At most 192 encoded bytes |
104+| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 |
114105 | Phone-to-watch dictionary | At most 768 encoded bytes |
106+| Watch-to-phone dictionary | At most 192 encoded bytes |
115107 | 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 |
116111
117-The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md).
118-
119-## Documentation
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.
120113
121-- [Contracts](docs/contracts.md)
122-- [Toolchain](docs/toolchain.md)
123-- [Platforms](docs/platforms.md)
124-- [Performance and battery](docs/performance.md)
125-- [Localization](docs/localization.md)
126-- [Evidence ledger](docs/evidence.md)
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.
127115
128116 ## Contributing
129117
130-Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates.
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.
@@ -20,46 +20,43 @@
20 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">20 <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">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">22 <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">
23- <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status">23+ <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">
24 </p>24 </p>
25 25
26-> [!IMPORTANT]
27-> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host.
28-
29 ## About26 ## About
30 27
31 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.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.
32 29
33 ```text30 ```text
34-Minimal Piu fixture view31+Departure and traffic views
35 │ symbolic AppMessage aliases32 │ symbolic AppMessage aliases
36 ▼33 ▼
37-PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM34+PebbleKit JS companion ── 60 s cache ──► HTTPS PRIM
38 │35 │
39- └── phone-local favorites + personal key36+ └── phone-local favorites (six) + personal key
40 ```37 ```
41 38
42-**The watch never receives the personal PRIM key and never starts network or background work.**39+**The watch never receives the personal PRIM key and never starts network or background requests.**
43 40
44 ## Highlights41 ## Highlights
45 42
46-- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap.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.
47-- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`.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.
48-- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state.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.
49-- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR.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.
50-- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two.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.
51-- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary.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.
52 49
53 ## Architecture50 ## Architecture
54 51
55 | Component | Owns | Must not |52 | Component | Owns | Must not |
56 |---|---|---|53 |---|---|---|
57-| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences |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 |
58-| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening |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 |
59-| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage |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 |
60-| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage |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 |
61 58
62-The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`.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.
63 60
64 ## Targets and requirements61 ## Targets and requirements
65 62
@@ -70,14 +67,12 @@ The embedded modules are `main`, `controller`, `model`, `protocol`, `message-que
70 | Pebble SDK | 4.33.1 |67 | Pebble SDK | 4.33.1 |
71 | Pebble CLI | `pebble-tool` 5.0.40 |68 | Pebble CLI | `pebble-tool` 5.0.40 |
72 | Host runtime | Node.js 24.18.0 |69 | Host runtime | Node.js 24.18.0 |
73-| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media |70+| Watch UI | Piu, four pinned built-in font roles (regular14, bold14, bold18, bold36), `touchCount=0`, no media |
74-
75-See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands.
76 71
77 72
78 ## Build from source73 ## Build from source
79 74
80-Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain.75+Install the pinned prerequisites from the table above. No repository script installs or upgrades a toolchain.
81 76
82 ```sh77 ```sh
83 npm test78 npm test
@@ -85,19 +80,20 @@ npm run measure:radio
85 npm run build80 npm run build
86 ```81 ```
87 82
88-`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs: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:
89 84
90 ```sh85 ```sh
91 LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \86 LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \
92-LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \
93 npm run build87 npm run build
94 ```88 ```
95 89
96 Personal PRIM keys are never build inputs.90 Personal PRIM keys are never build inputs.
97 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+
98 ## Privacy94 ## Privacy
99 95
100-The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.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.
101 97
102 Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.98 Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.
103 99
@@ -105,26 +101,18 @@ Lapin Futé has no telemetry. The watch has no network module and starts no peri
105 101
106 | Gate | Budget |102 | Gate | Budget |
107 |---|---|103 |---|---|
108-| Alloy app binary | At most 120 KiB on each target |104+| Native VM memory | Fixed 57,344-byte split: stack 3,584, slots 41,472, chunks 12,288 |
109-| Loaded image and RAM | At most 56 KiB each |
110-| XS arenas | At least 25% free at the worst measured point |
111-| Settled XS drift | At most 1 KiB over the final ten of 50 cycles |
112-| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes |
113-| Watch request | At most 192 encoded bytes |
114 | Phone-to-watch dictionary | At most 768 encoded bytes |105 | Phone-to-watch dictionary | At most 768 encoded bytes |
106+| Watch-to-phone dictionary | At most 192 encoded bytes |
115 | Watch unsent queue | Four messages, at most once, one write per `onWritable` |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 |
116 111
117-The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md).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.
118-
119-## Documentation
120 113
121-- [Contracts](docs/contracts.md)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.
122-- [Toolchain](docs/toolchain.md)
123-- [Platforms](docs/platforms.md)
124-- [Performance and battery](docs/performance.md)
125-- [Localization](docs/localization.md)
126-- [Evidence ledger](docs/evidence.md)
127 115
128 ## Contributing116 ## Contributing
129 117
130-Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates.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.
modified fixtures/catalog/idfm-v1.json +36 -37
@@ -75,12 +75,11 @@
7575 { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" },
7676 { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" },
7777 { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" },
78- { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
79- { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },
80- { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },
81- { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },
82- { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },
83- { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
78+ { "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_name": "Châtelet RER est", "parent_station": "IDFM:ZDC-RER" },
79+ { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },
80+ { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },
81+ { "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:ZDC-TRANSILIEN" },
82+ { "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
8483 { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" },
8584 { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" }
8685 ],
@@ -109,35 +108,35 @@
109108 { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] },
110109 { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" },
111110 { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" },
112- { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" },
113- { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },
114- { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" },
115- { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },
116- { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },
117- { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },
111+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" },
112+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },
113+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" },
114+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },
115+ { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },
116+ { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },
118117 { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" },
119118 { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" }
120119 ],
121120 "objectCodes": [
122- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" },
121+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "source", "object_code": "FR::Quay:ART-BUS-1:FR1" },
123122 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" },
124- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" },
123+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "source", "object_code": "FR::Quay:ART-BUS-2:FR1" },
125124 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" },
126- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" },
125+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "source", "object_code": "FR::Quay:ART-BUS-MISMATCH:FR1" },
127126 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" },
128- { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" },
127+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-EAST:FR1" },
129128 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" },
130- { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] },
129+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-WEST:FR1", "revisions": ["fixture-2026-08-a"] },
131130 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] },
132- { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" },
131+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "source", "object_code": "FR::Quay:ART-TRAM:FR1" },
133132 { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" },
134- { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
135- { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },
136- { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },
137- { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },
138- { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" },
139- { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },
140- { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" },
133+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" },
134+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },
135+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" },
136+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },
137+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-TRANSILIEN:FR1" },
138+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },
139+ { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "source", "object_code": "FR::Quay:ART-FUNICULAR:FR1" },
141140 { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" }
142141 ],
143142 "arretsLignes": [
@@ -147,21 +146,21 @@
147146 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" },
148147 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" },
149148 { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" },
150- { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" },
151- { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" },
152- { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" },
149+ { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" },
150+ { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" },
151+ { "id": "IDFM:C500", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },
153152 { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" }
154153 ],
155154 "perimeter": [
156- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
157- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
158- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },
159- { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },
160- { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },
161- { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },
162- { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },
163- { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] },
164- { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }
155+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
156+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
157+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },
158+ { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },
159+ { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },
160+ { "line": "STIF:Line::C300:", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:ART-TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },
161+ { "line": "STIF:Line::C400:", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-RER:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },
162+ { "line": "STIF:Line::C500:", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-TRANSILIEN:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a","fixture-2026-08-b"] },
163+ { "line": "STIF:Line::C600:", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:ART-FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }
165164 ]
166165 }
167166 }
@@ -75,12 +75,11 @@
75 { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" },75 { "stop_id": "IDFM:TERM-METRO-NORTH", "stop_name": "Porte de Clignancourt", "parent_station": "" },
76 { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" },76 { "stop_id": "IDFM:ART-TRAM", "stop_name": "Porte de Versailles quai tram", "parent_station": "IDFM:ARR-TRAM" },
77 { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" },77 { "stop_id": "IDFM:TERM-TRAM", "stop_name": "Pont du Garigliano", "parent_station": "" },
78- { "stop_id": "IDFM:ART-RER-EAST", "stop_name": "Châtelet RER est", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },78+ { "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_name": "Châtelet RER est", "parent_station": "IDFM:ZDC-RER" },
79- { "stop_id": "IDFM:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },79+ { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_name": "Marne-la-Vallée Chessy", "parent_station": "" },
80- { "stop_id": "IDFM:ART-RER-WEST", "stop_name": "Châtelet RER ouest", "parent_station": "IDFM:monomodalStopPlace:ZDA-RER" },80+ { "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },
81- { "stop_id": "IDFM:TERM-RER-WEST", "stop_name": "Saint-Germain-en-Laye", "parent_station": "" },81+ { "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:ZDC-TRANSILIEN" },
82- { "stop_id": "IDFM:ART-TRANSILIEN", "stop_name": "Saint-Lazare quai L", "parent_station": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },82+ { "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
83- { "stop_id": "IDFM:TERM-TRANSILIEN", "stop_name": "Versailles Rive Droite", "parent_station": "" },
84 { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" },83 { "stop_id": "IDFM:ART-FUNICULAR", "stop_name": "Funiculaire bas", "parent_station": "IDFM:ARR-FUNICULAR" },
85 { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" }84 { "stop_id": "IDFM:TERM-FUNICULAR", "stop_name": "Funiculaire haut", "parent_station": "" }
86 ],85 ],
@@ -109,35 +108,35 @@
109 { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] },108 { "trip_id": "trip-metro-west", "stop_id": "IDFM:TERM-METRO-NORTH", "stop_sequence": "2", "pickup_type": "1", "revisions": ["fixture-2026-08-a"] },
110 { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" },109 { "trip_id": "trip-tram", "stop_id": "IDFM:ART-TRAM", "stop_sequence": "1", "pickup_type": "0" },
111 { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" },110 { "trip_id": "trip-tram", "stop_id": "IDFM:TERM-TRAM", "stop_sequence": "2", "pickup_type": "1" },
112- { "trip_id": "trip-rer-east", "stop_id": "IDFM:ART-RER-EAST", "stop_sequence": "1", "pickup_type": "0" },111+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" },
113- { "trip_id": "trip-rer-east", "stop_id": "IDFM:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },112+ { "trip_id": "trip-rer-east", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "stop_sequence": "2", "pickup_type": "1" },
114- { "trip_id": "trip-rer-west", "stop_id": "IDFM:ART-RER-WEST", "stop_sequence": "1", "pickup_type": "0" },113+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER", "stop_sequence": "1", "pickup_type": "0" },
115- { "trip_id": "trip-rer-west", "stop_id": "IDFM:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },114+ { "trip_id": "trip-rer-west", "stop_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "stop_sequence": "2", "pickup_type": "1" },
116- { "trip_id": "trip-transilien", "stop_id": "IDFM:ART-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },115+ { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "stop_sequence": "1", "pickup_type": "0" },
117- { "trip_id": "trip-transilien", "stop_id": "IDFM:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },116+ { "trip_id": "trip-transilien", "stop_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "stop_sequence": "2", "pickup_type": "1" },
118 { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" },117 { "trip_id": "trip-funicular", "stop_id": "IDFM:ART-FUNICULAR", "stop_sequence": "1", "pickup_type": "0" },
119 { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" }118 { "trip_id": "trip-funicular", "stop_id": "IDFM:TERM-FUNICULAR", "stop_sequence": "2", "pickup_type": "1" }
120 ],119 ],
121 "objectCodes": [120 "objectCodes": [
122- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-1:" },121+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-1", "object_system": "source", "object_code": "FR::Quay:ART-BUS-1:FR1" },
123 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" },122 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-1", "object_system": "source", "object_code": "DEST:BUS-1" },
124- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-2:" },123+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-2", "object_system": "source", "object_code": "FR::Quay:ART-BUS-2:FR1" },
125 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" },124 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-2", "object_system": "source", "object_code": "DEST:BUS-2" },
126- { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:BUS-MISSING:" },125+ { "object_type": "StopPoint", "object_id": "IDFM:ART-BUS-MISMATCH", "object_system": "source", "object_code": "FR::Quay:ART-BUS-MISMATCH:FR1" },
127 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" },126 { "object_type": "StopPoint", "object_id": "IDFM:TERM-BUS-MISMATCH", "object_system": "source", "object_code": "DEST:BUS-MISMATCH" },
128- { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-EAST:" },127+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-EAST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-EAST:FR1" },
129 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" },128 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-SOUTH", "object_system": "source", "object_code": "DEST:METRO-SOUTH" },
130- { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:METRO-WEST:", "revisions": ["fixture-2026-08-a"] },129+ { "object_type": "StopPoint", "object_id": "IDFM:ART-METRO-WEST", "object_system": "source", "object_code": "FR::Quay:ART-METRO-WEST:FR1", "revisions": ["fixture-2026-08-a"] },
131 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] },130 { "object_type": "StopPoint", "object_id": "IDFM:TERM-METRO-NORTH", "object_system": "source", "object_code": "DEST:METRO-NORTH", "revisions": ["fixture-2026-08-a"] },
132- { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:TRAM:" },131+ { "object_type": "StopPoint", "object_id": "IDFM:ART-TRAM", "object_system": "source", "object_code": "FR::Quay:ART-TRAM:FR1" },
133 { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" },132 { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRAM", "object_system": "source", "object_code": "DEST:TRAM" },
134- { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-EAST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },133+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" },
135- { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },134+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-EAST", "object_system": "source", "object_code": "DEST:RER-EAST" },
136- { "object_type": "StopPoint", "object_id": "IDFM:ART-RER-WEST", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:RER-CHAT:" },135+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-RER", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-RER:FR1" },
137- { "object_type": "StopPoint", "object_id": "IDFM:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },136+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-RER-WEST", "object_system": "source", "object_code": "DEST:RER-WEST" },
138- { "object_type": "StopPoint", "object_id": "IDFM:ART-TRANSILIEN", "object_system": "SIRI", "object_code": "STIF:StopArea:SP:TRANSILIEN-STL:" },137+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN", "object_system": "source", "object_code": "FR::monomodalStopPlace:ZDA-TRANSILIEN:FR1" },
139- { "object_type": "StopPoint", "object_id": "IDFM:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },138+ { "object_type": "StopPoint", "object_id": "IDFM:monomodalStopPlace:TERM-TRANSILIEN", "object_system": "source", "object_code": "DEST:TRANSILIEN" },
140- { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "SIRI", "object_code": "STIF:StopPoint:Q:FUNICULAR:" },139+ { "object_type": "StopPoint", "object_id": "IDFM:ART-FUNICULAR", "object_system": "source", "object_code": "FR::Quay:ART-FUNICULAR:FR1" },
141 { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" }140 { "object_type": "StopPoint", "object_id": "IDFM:TERM-FUNICULAR", "object_system": "source", "object_code": "DEST:FUNICULAR" }
142 ],141 ],
143 "arretsLignes": [142 "arretsLignes": [
@@ -147,21 +146,21 @@
147 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" },146 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-EAST" },
148 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" },147 { "id": "IDFM:C200", "stop_id": "IDFM:ART-METRO-WEST" },
149 { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" },148 { "id": "IDFM:C300", "stop_id": "IDFM:ART-TRAM" },
150- { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-EAST" },149+ { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" },
151- { "id": "IDFM:C400", "stop_id": "IDFM:ART-RER-WEST" },150+ { "id": "IDFM:C400", "stop_id": "IDFM:monomodalStopPlace:ZDA-RER" },
152- { "id": "IDFM:C500", "stop_id": "IDFM:ART-TRANSILIEN" },151+ { "id": "IDFM:C500", "stop_id": "IDFM:monomodalStopPlace:ZDA-TRANSILIEN" },
153 { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" }152 { "id": "IDFM:C600", "stop_id": "IDFM:ART-FUNICULAR" }
154 ],153 ],
155 "perimeter": [154 "perimeter": [
156- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },155+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-1:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
157- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },156+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:ART-BUS-2:", "ns2_stopname": "Gare du Nord", "operatorname": "Fixture bus" },
158- { "line": "IDFM:C100", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },157+ { "line": "STIF:Line::C100:", "name_line": "42", "ns2_stoppointref": "STIF:StopPoint:Q:BUS-OTHER:", "ns2_stopname": "Mismatch", "operatorname": "Fixture bus" },
159- { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },158+ { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-EAST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro" },
160- { "line": "IDFM:C200", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },159+ { "line": "STIF:Line::C200:", "name_line": "4", "ns2_stoppointref": "STIF:StopPoint:Q:ART-METRO-WEST:", "ns2_stopname": "Châtelet", "operatorname": "Fixture metro", "revisions": ["fixture-2026-08-a"] },
161- { "line": "IDFM:C300", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },160+ { "line": "STIF:Line::C300:", "name_line": "T3a", "ns2_stoppointref": "STIF:StopPoint:Q:ART-TRAM:", "ns2_stopname": "Porte de Versailles", "operatorname": "Fixture tram" },
162- { "line": "IDFM:C400", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:RER-CHAT:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },161+ { "line": "STIF:Line::C400:", "name_line": "A", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-RER:", "ns2_stopname": "Châtelet - Les Halles", "operatorname": "Fixture rail" },
163- { "line": "IDFM:C500", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:TRANSILIEN-STL:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a", "fixture-2026-08-b"] },162+ { "line": "STIF:Line::C500:", "name_line": "L", "ns2_stoppointref": "STIF:StopArea:SP:ZDA-TRANSILIEN:", "ns2_stopname": "Saint-Lazare", "operatorname": "Fixture rail", "revisions": ["fixture-2026-08-a","fixture-2026-08-b"] },
164- { "line": "IDFM:C600", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }163+ { "line": "STIF:Line::C600:", "name_line": "F", "ns2_stoppointref": "STIF:StopPoint:Q:ART-FUNICULAR:", "ns2_stopname": "Montmartre", "operatorname": "Fixture funicular" }
165 ]164 ]
166 }165 }
167 }166 }
added fixtures/traffic/global.json +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+{
2+ "disruptions": [
3+ {
4+ "id": "delay-active",
5+ "applicationPeriods": [
6+ { "begin": "20260115T095900", "end": "20260115T110000" }
7+ ],
8+ "lastUpdate": "20260115T095500",
9+ "cause": "PERTURBATION",
10+ "severity": "PERTURBEE",
11+ "title": "Métro 2 : ralentissements",
12+ "message": "<p>Le trafic est ralenti &amp; les temps d’attente sont allongés.</p>"
13+ },
14+ {
15+ "id": "information-active",
16+ "applicationPeriods": [
17+ { "begin": "20260115T090000", "end": "20260115T120000" }
18+ ],
19+ "lastUpdate": "20260115T094500",
20+ "cause": "INFORMATION",
21+ "severity": "INFORMATION",
22+ "title": "Information voyageurs",
23+ "message": "<p>La circulation a repris normalement.</p>"
24+ },
25+ {
26+ "id": "stopped-active",
27+ "applicationPeriods": [
28+ { "begin": "20260115T093000", "end": "20260115T103000" }
29+ ],
30+ "lastUpdate": "20260115T095800",
31+ "cause": "PERTURBATION",
32+ "severity": { "name": "BLOQUANTE", "effect": "NO_SERVICE" },
33+ "title": "RER A : trafic interrompu",
34+ "message": "<p>Le trafic est interrompu entre les deux terminus.</p>"
35+ },
36+ {
37+ "id": "blocking-unclassified",
38+ "applicationPeriods": [
39+ { "begin": "20260115T093000", "end": "20260115T103000" }
40+ ],
41+ "lastUpdate": "20260115T095700",
42+ "cause": "PERTURBATION",
43+ "severity": "BLOQUANTE",
44+ "title": "Tram T3 : incident",
45+ "message": "<p>Une perturbation est en cours.</p>"
46+ },
47+ {
48+ "id": "future-delay",
49+ "applicationPeriods": [
50+ { "begin": "20260115T120000", "end": "20260115T130000" }
51+ ],
52+ "lastUpdate": "20260115T090000",
53+ "cause": "TRAVAUX",
54+ "severity": "PERTURBEE",
55+ "title": "Travaux prévus",
56+ "message": "<p>Des travaux sont prévus plus tard.</p>"
57+ }
58+ ],
59+ "lines": [
60+ {
61+ "id": "line:IDFM:C100",
62+ "name": "1",
63+ "shortName": "1",
64+ "mode": "Metro",
65+ "networkId": "network:IDFM:Operator_100",
66+ "impactedObjects": []
67+ },
68+ {
69+ "id": "line:IDFM:C200",
70+ "name": "2",
71+ "shortName": "2",
72+ "mode": "Metro",
73+ "networkId": "network:IDFM:Operator_100",
74+ "impactedObjects": [
75+ {
76+ "type": "line",
77+ "id": "line:IDFM:C200",
78+ "name": "2",
79+ "disruptionIds": ["delay-active", "information-active", "future-delay"]
80+ }
81+ ]
82+ },
83+ {
84+ "id": "line:IDFM:C300",
85+ "name": "A",
86+ "shortName": "A",
87+ "mode": "RapidTransit",
88+ "networkId": "network:IDFM:71",
89+ "impactedObjects": [
90+ {
91+ "type": "line",
92+ "id": "line:IDFM:C300",
93+ "name": "A",
94+ "disruptionIds": ["delay-active", "blocking-unclassified", "stopped-active"]
95+ }
96+ ]
97+ },
98+ {
99+ "id": "line:IDFM:C400",
100+ "name": "T3",
101+ "shortName": "T3",
102+ "mode": "Tramway",
103+ "networkId": "network:IDFM:Operator_100",
104+ "impactedObjects": [
105+ {
106+ "type": "line",
107+ "id": "line:IDFM:C400",
108+ "name": "T3",
109+ "disruptionIds": ["delay-active", "blocking-unclassified"]
110+ }
111+ ]
112+ },
113+ {
114+ "id": "line:IDFM:C500",
115+ "name": "5",
116+ "shortName": "5",
117+ "mode": "Metro",
118+ "networkId": "network:IDFM:Operator_100",
119+ "impactedObjects": [
120+ {
121+ "type": "line",
122+ "id": "line:IDFM:C500",
123+ "name": "5",
124+ "disruptionIds": ["information-active"]
125+ }
126+ ]
127+ },
128+ {
129+ "id": "line:IDFM:C600",
130+ "name": "6",
131+ "shortName": "6",
132+ "mode": "Metro",
133+ "networkId": "network:IDFM:Operator_100",
134+ "impactedObjects": [
135+ {
136+ "type": "stop_point",
137+ "id": "stop_point:IDFM:12345",
138+ "name": "Un arrêt",
139+ "disruptionIds": ["delay-active"]
140+ }
141+ ]
142+ }
143+ ],
144+ "lastUpdatedDate": "2026-01-15T09:00:00.000Z",
145+ "unknownFutureField": { "accepted": true }
146+}
new file mode 100644
@@ -0,0 +1,146 @@
1+{
2+ "disruptions": [
3+ {
4+ "id": "delay-active",
5+ "applicationPeriods": [
6+ { "begin": "20260115T095900", "end": "20260115T110000" }
7+ ],
8+ "lastUpdate": "20260115T095500",
9+ "cause": "PERTURBATION",
10+ "severity": "PERTURBEE",
11+ "title": "Métro 2 : ralentissements",
12+ "message": "<p>Le trafic est ralenti &amp; les temps d’attente sont allongés.</p>"
13+ },
14+ {
15+ "id": "information-active",
16+ "applicationPeriods": [
17+ { "begin": "20260115T090000", "end": "20260115T120000" }
18+ ],
19+ "lastUpdate": "20260115T094500",
20+ "cause": "INFORMATION",
21+ "severity": "INFORMATION",
22+ "title": "Information voyageurs",
23+ "message": "<p>La circulation a repris normalement.</p>"
24+ },
25+ {
26+ "id": "stopped-active",
27+ "applicationPeriods": [
28+ { "begin": "20260115T093000", "end": "20260115T103000" }
29+ ],
30+ "lastUpdate": "20260115T095800",
31+ "cause": "PERTURBATION",
32+ "severity": { "name": "BLOQUANTE", "effect": "NO_SERVICE" },
33+ "title": "RER A : trafic interrompu",
34+ "message": "<p>Le trafic est interrompu entre les deux terminus.</p>"
35+ },
36+ {
37+ "id": "blocking-unclassified",
38+ "applicationPeriods": [
39+ { "begin": "20260115T093000", "end": "20260115T103000" }
40+ ],
41+ "lastUpdate": "20260115T095700",
42+ "cause": "PERTURBATION",
43+ "severity": "BLOQUANTE",
44+ "title": "Tram T3 : incident",
45+ "message": "<p>Une perturbation est en cours.</p>"
46+ },
47+ {
48+ "id": "future-delay",
49+ "applicationPeriods": [
50+ { "begin": "20260115T120000", "end": "20260115T130000" }
51+ ],
52+ "lastUpdate": "20260115T090000",
53+ "cause": "TRAVAUX",
54+ "severity": "PERTURBEE",
55+ "title": "Travaux prévus",
56+ "message": "<p>Des travaux sont prévus plus tard.</p>"
57+ }
58+ ],
59+ "lines": [
60+ {
61+ "id": "line:IDFM:C100",
62+ "name": "1",
63+ "shortName": "1",
64+ "mode": "Metro",
65+ "networkId": "network:IDFM:Operator_100",
66+ "impactedObjects": []
67+ },
68+ {
69+ "id": "line:IDFM:C200",
70+ "name": "2",
71+ "shortName": "2",
72+ "mode": "Metro",
73+ "networkId": "network:IDFM:Operator_100",
74+ "impactedObjects": [
75+ {
76+ "type": "line",
77+ "id": "line:IDFM:C200",
78+ "name": "2",
79+ "disruptionIds": ["delay-active", "information-active", "future-delay"]
80+ }
81+ ]
82+ },
83+ {
84+ "id": "line:IDFM:C300",
85+ "name": "A",
86+ "shortName": "A",
87+ "mode": "RapidTransit",
88+ "networkId": "network:IDFM:71",
89+ "impactedObjects": [
90+ {
91+ "type": "line",
92+ "id": "line:IDFM:C300",
93+ "name": "A",
94+ "disruptionIds": ["delay-active", "blocking-unclassified", "stopped-active"]
95+ }
96+ ]
97+ },
98+ {
99+ "id": "line:IDFM:C400",
100+ "name": "T3",
101+ "shortName": "T3",
102+ "mode": "Tramway",
103+ "networkId": "network:IDFM:Operator_100",
104+ "impactedObjects": [
105+ {
106+ "type": "line",
107+ "id": "line:IDFM:C400",
108+ "name": "T3",
109+ "disruptionIds": ["delay-active", "blocking-unclassified"]
110+ }
111+ ]
112+ },
113+ {
114+ "id": "line:IDFM:C500",
115+ "name": "5",
116+ "shortName": "5",
117+ "mode": "Metro",
118+ "networkId": "network:IDFM:Operator_100",
119+ "impactedObjects": [
120+ {
121+ "type": "line",
122+ "id": "line:IDFM:C500",
123+ "name": "5",
124+ "disruptionIds": ["information-active"]
125+ }
126+ ]
127+ },
128+ {
129+ "id": "line:IDFM:C600",
130+ "name": "6",
131+ "shortName": "6",
132+ "mode": "Metro",
133+ "networkId": "network:IDFM:Operator_100",
134+ "impactedObjects": [
135+ {
136+ "type": "stop_point",
137+ "id": "stop_point:IDFM:12345",
138+ "name": "Un arrêt",
139+ "disruptionIds": ["delay-active"]
140+ }
141+ ]
142+ }
143+ ],
144+ "lastUpdatedDate": "2026-01-15T09:00:00.000Z",
145+ "unknownFutureField": { "accepted": true }
146+}
modified package-lock.json +4 -4
@@ -14,8 +14,8 @@
1414 "node": "24.18.0"
1515 }
1616 },
17- "node_modules/@lapin-fute/backend": {
18- "resolved": "packages/backend",
17+ "node_modules/@lapin-fute/catalog": {
18+ "resolved": "packages/catalog",
1919 "link": true
2020 },
2121 "node_modules/@lapin-fute/companion": {
@@ -54,8 +54,8 @@
5454 "node": ">=12"
5555 }
5656 },
57- "packages/backend": {
58- "name": "@lapin-fute/backend",
57+ "packages/catalog": {
58+ "name": "@lapin-fute/catalog",
5959 "version": "0.1.0",
6060 "dependencies": {
6161 "csv-parse": "7.0.2",
@@ -14,8 +14,8 @@
14 "node": "24.18.0"14 "node": "24.18.0"
15 }15 }
16 },16 },
17- "node_modules/@lapin-fute/backend": {17+ "node_modules/@lapin-fute/catalog": {
18- "resolved": "packages/backend",18+ "resolved": "packages/catalog",
19 "link": true19 "link": true
20 },20 },
21 "node_modules/@lapin-fute/companion": {21 "node_modules/@lapin-fute/companion": {
@@ -54,8 +54,8 @@
54 "node": ">=12"54 "node": ">=12"
55 }55 }
56 },56 },
57- "packages/backend": {57+ "packages/catalog": {
58- "name": "@lapin-fute/backend",58+ "name": "@lapin-fute/catalog",
59 "version": "0.1.0",59 "version": "0.1.0",
60 "dependencies": {60 "dependencies": {
61 "csv-parse": "7.0.2",61 "csv-parse": "7.0.2",
modified package.json +2 -1
@@ -11,6 +11,7 @@
1111 },
1212 "scripts": {
1313 "build": "node scripts/build.mjs",
14+ "build:config-site": "node scripts/build-config-site.mjs",
1415 "catalog:probe": "node scripts/probe-catalog.mjs",
1516 "catalog:refresh": "node scripts/refresh-catalog.mjs",
1617 "badges:sync": "node scripts/sync-line-badges.mjs",
@@ -19,7 +20,7 @@
1920 "measure:config-page": "node scripts/measure-config-page.mjs",
2021 "test": "node scripts/test.mjs",
2122 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
22- "test:watch": "node --test packages/watch/test/*.test.js",
23+ "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
2324 "verify:watch-build": "node scripts/verify-watch-build.mjs"
2425 }
2526 }
@@ -11,6 +11,7 @@
11 },11 },
12 "scripts": {12 "scripts": {
13 "build": "node scripts/build.mjs",13 "build": "node scripts/build.mjs",
14+ "build:config-site": "node scripts/build-config-site.mjs",
14 "catalog:probe": "node scripts/probe-catalog.mjs",15 "catalog:probe": "node scripts/probe-catalog.mjs",
15 "catalog:refresh": "node scripts/refresh-catalog.mjs",16 "catalog:refresh": "node scripts/refresh-catalog.mjs",
16 "badges:sync": "node scripts/sync-line-badges.mjs",17 "badges:sync": "node scripts/sync-line-badges.mjs",
@@ -19,7 +20,7 @@
19 "measure:config-page": "node scripts/measure-config-page.mjs",20 "measure:config-page": "node scripts/measure-config-page.mjs",
20 "test": "node scripts/test.mjs",21 "test": "node scripts/test.mjs",
21 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",22 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
22- "test:watch": "node --test packages/watch/test/*.test.js",23+ "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
23 "verify:watch-build": "node scripts/verify-watch-build.mjs"24 "verify:watch-build": "node scripts/verify-watch-build.mjs"
24 }25 }
25 }26 }
deleted packages/backend/src/departure-endpoint.ts +0 -501
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/src/departure-service.ts +0 -227
deleted file mode 100644
@@ -1,227 +0,0 @@
1-import {
2- SCHEMA_VERSION,
3- type ErrorCode,
4-} from "../../contracts/src/index.ts";
5-
6-import type { CatalogReader } from "./catalog.ts";
7-import {
8- DEPARTURE_METRIC_NAME,
9- type DepartureMetricName,
10- type DepartureMetrics,
11- type DepartureResolver,
12- type PublicDepartureData,
13- type PublicDepartureError,
14-} from "./departures.ts";
15-import {
16- buildPrimStopMonitoringUrl,
17- normalizePrimDepartureResponse,
18- parseRetryAfterSeconds,
19-} from "./prim-departures.ts";
20-
21-export interface PrimDepartureRelayRequest {
22- readonly target: URL;
23- readonly apiKey: string;
24-}
25-
26-export interface PrimDepartureRelayResponse {
27- readonly status: number;
28- readonly bytes: Uint8Array;
29- readonly retryAfter?: string;
30-}
31-
32-export type PrimDepartureRelay = (
33- request: PrimDepartureRelayRequest,
34-) => Promise<PrimDepartureRelayResponse>;
35-
36-export interface UpstreamLimiter {
37- run<T>(operation: () => Promise<T>): Promise<T>;
38-}
39-
40-export interface PrimDepartureResolverDependencies {
41- readonly catalog: CatalogReader;
42- readonly relay: PrimDepartureRelay;
43- readonly clock?: () => number;
44- readonly metrics?: DepartureMetrics;
45- readonly limiter?: UpstreamLimiter;
46-}
47-
48-export const MAX_UPSTREAM_CALLS = 8 as const;
49-const UINT32_MAX = 0xffff_ffff;
50-const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
51-
52-export function createUpstreamLimiter(
53- maxActive = MAX_UPSTREAM_CALLS,
54-): UpstreamLimiter {
55- if (!Number.isInteger(maxActive) || maxActive < 1) {
56- throw new RangeError("maxActive must be a positive integer");
57- }
58-
59- let active = 0;
60- const queue: Array<() => void> = [];
61-
62- async function acquire(): Promise<void> {
63- if (active < maxActive) {
64- active += 1;
65- return;
66- }
67-
68- await new Promise<void>((resolve) => {
69- queue.push(() => {
70- active += 1;
71- resolve();
72- });
73- });
74- }
75-
76- function release(): void {
77- active -= 1;
78- queue.shift()?.();
79- }
80-
81- return Object.freeze({
82- async run<T>(operation: () => Promise<T>): Promise<T> {
83- await acquire();
84- try {
85- return await operation();
86- } finally {
87- release();
88- }
89- },
90- });
91-}
92-
93-const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter();
94-
95-function nowMilliseconds(clock: (() => number) | undefined): number {
96- try {
97- const value = clock?.() ?? Date.now();
98- return Number.isFinite(value) && value >= 0 ? value : Date.now();
99- } catch {
100- return Date.now();
101- }
102-}
103-
104-function metric(
105- metrics: DepartureMetrics | undefined,
106- name: DepartureMetricName,
107- value: number,
108-): void {
109- if (!Number.isFinite(value) || value < 0) return;
110- try {
111- metrics?.record(name, value);
112- } catch {
113- // Instrumentation must never affect the relay or request settlement.
114- }
115-}
116-
117-function epochSeconds(milliseconds: number): number {
118- return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000));
119-}
120-
121-function publicError(
122- code: ErrorCode,
123- milliseconds: number,
124- retryAfterSeconds?: number,
125-): PublicDepartureError {
126- return {
127- schemaVersion: SCHEMA_VERSION,
128- code,
129- occurredAt: epochSeconds(milliseconds),
130- ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
131- };
132-}
133-
134-interface RelayResponseEnvelope {
135- readonly status: number;
136- readonly bytes?: unknown;
137- readonly retryAfter?: unknown;
138-}
139-
140-function validRelayResponse(response: unknown): response is RelayResponseEnvelope {
141- if (typeof response !== "object" || response === null || !("status" in response)) {
142- return false;
143- }
144- const status = response.status;
145- return typeof status === "number"
146- && Number.isInteger(status)
147- && status >= 100
148- && status <= 599;
149-}
150-
151-export function createPrimDepartureResolver(
152- dependencies: PrimDepartureResolverDependencies,
153-): DepartureResolver {
154- const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER;
155-
156- return async (request) => {
157- const resolution = dependencies.catalog.resolveService(request.serviceId);
158- if (resolution.status !== "RESOLVED") {
159- return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock));
160- }
161-
162- const target = buildPrimStopMonitoringUrl(resolution);
163- let apiKey = request.apiKey;
164- let response: unknown;
165- let completedAtMilliseconds: number | undefined;
166- try {
167- response = await limiter.run(async () => {
168- metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1);
169- const startedAtMilliseconds = nowMilliseconds(dependencies.clock);
170- let status = 0;
171- try {
172- const relayResponse = await dependencies.relay({ target, apiKey });
173- status = validRelayResponse(relayResponse) ? relayResponse.status : 0;
174- return relayResponse;
175- } finally {
176- completedAtMilliseconds = nowMilliseconds(dependencies.clock);
177- metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status);
178- metric(
179- dependencies.metrics,
180- DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS,
181- Math.max(0, completedAtMilliseconds - startedAtMilliseconds),
182- );
183- }
184- });
185- } catch {
186- return publicError(
187- "SOURCE_UNAVAILABLE",
188- completedAtMilliseconds ?? nowMilliseconds(dependencies.clock),
189- );
190- } finally {
191- apiKey = "";
192- }
193-
194- const receivedAtMilliseconds =
195- completedAtMilliseconds ?? nowMilliseconds(dependencies.clock);
196- if (!validRelayResponse(response)) {
197- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
198- }
199- if (response.status === 401 || response.status === 403) {
200- return publicError("API_KEY_INVALID", receivedAtMilliseconds);
201- }
202- if (response.status === 429) {
203- return publicError(
204- "RATE_LIMITED",
205- receivedAtMilliseconds,
206- parseRetryAfterSeconds(response.retryAfter),
207- );
208- }
209- if (response.status < 200 || response.status >= 300) {
210- return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds);
211- }
212- if (!(response.bytes instanceof Uint8Array)) {
213- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
214- }
215-
216- try {
217- const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown;
218- return normalizePrimDepartureResponse(
219- payload,
220- resolution,
221- epochSeconds(receivedAtMilliseconds),
222- ) as PublicDepartureData;
223- } catch {
224- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
225- }
226- };
227-}
deleted file mode 100644
@@ -1,227 +0,0 @@
1-import {
2- SCHEMA_VERSION,
3- type ErrorCode,
4-} from "../../contracts/src/index.ts";
5-
6-import type { CatalogReader } from "./catalog.ts";
7-import {
8- DEPARTURE_METRIC_NAME,
9- type DepartureMetricName,
10- type DepartureMetrics,
11- type DepartureResolver,
12- type PublicDepartureData,
13- type PublicDepartureError,
14-} from "./departures.ts";
15-import {
16- buildPrimStopMonitoringUrl,
17- normalizePrimDepartureResponse,
18- parseRetryAfterSeconds,
19-} from "./prim-departures.ts";
20-
21-export interface PrimDepartureRelayRequest {
22- readonly target: URL;
23- readonly apiKey: string;
24-}
25-
26-export interface PrimDepartureRelayResponse {
27- readonly status: number;
28- readonly bytes: Uint8Array;
29- readonly retryAfter?: string;
30-}
31-
32-export type PrimDepartureRelay = (
33- request: PrimDepartureRelayRequest,
34-) => Promise<PrimDepartureRelayResponse>;
35-
36-export interface UpstreamLimiter {
37- run<T>(operation: () => Promise<T>): Promise<T>;
38-}
39-
40-export interface PrimDepartureResolverDependencies {
41- readonly catalog: CatalogReader;
42- readonly relay: PrimDepartureRelay;
43- readonly clock?: () => number;
44- readonly metrics?: DepartureMetrics;
45- readonly limiter?: UpstreamLimiter;
46-}
47-
48-export const MAX_UPSTREAM_CALLS = 8 as const;
49-const UINT32_MAX = 0xffff_ffff;
50-const STRICT_UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
51-
52-export function createUpstreamLimiter(
53- maxActive = MAX_UPSTREAM_CALLS,
54-): UpstreamLimiter {
55- if (!Number.isInteger(maxActive) || maxActive < 1) {
56- throw new RangeError("maxActive must be a positive integer");
57- }
58-
59- let active = 0;
60- const queue: Array<() => void> = [];
61-
62- async function acquire(): Promise<void> {
63- if (active < maxActive) {
64- active += 1;
65- return;
66- }
67-
68- await new Promise<void>((resolve) => {
69- queue.push(() => {
70- active += 1;
71- resolve();
72- });
73- });
74- }
75-
76- function release(): void {
77- active -= 1;
78- queue.shift()?.();
79- }
80-
81- return Object.freeze({
82- async run<T>(operation: () => Promise<T>): Promise<T> {
83- await acquire();
84- try {
85- return await operation();
86- } finally {
87- release();
88- }
89- },
90- });
91-}
92-
93-const PROCESS_UPSTREAM_LIMITER = createUpstreamLimiter();
94-
95-function nowMilliseconds(clock: (() => number) | undefined): number {
96- try {
97- const value = clock?.() ?? Date.now();
98- return Number.isFinite(value) && value >= 0 ? value : Date.now();
99- } catch {
100- return Date.now();
101- }
102-}
103-
104-function metric(
105- metrics: DepartureMetrics | undefined,
106- name: DepartureMetricName,
107- value: number,
108-): void {
109- if (!Number.isFinite(value) || value < 0) return;
110- try {
111- metrics?.record(name, value);
112- } catch {
113- // Instrumentation must never affect the relay or request settlement.
114- }
115-}
116-
117-function epochSeconds(milliseconds: number): number {
118- return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000));
119-}
120-
121-function publicError(
122- code: ErrorCode,
123- milliseconds: number,
124- retryAfterSeconds?: number,
125-): PublicDepartureError {
126- return {
127- schemaVersion: SCHEMA_VERSION,
128- code,
129- occurredAt: epochSeconds(milliseconds),
130- ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
131- };
132-}
133-
134-interface RelayResponseEnvelope {
135- readonly status: number;
136- readonly bytes?: unknown;
137- readonly retryAfter?: unknown;
138-}
139-
140-function validRelayResponse(response: unknown): response is RelayResponseEnvelope {
141- if (typeof response !== "object" || response === null || !("status" in response)) {
142- return false;
143- }
144- const status = response.status;
145- return typeof status === "number"
146- && Number.isInteger(status)
147- && status >= 100
148- && status <= 599;
149-}
150-
151-export function createPrimDepartureResolver(
152- dependencies: PrimDepartureResolverDependencies,
153-): DepartureResolver {
154- const limiter = dependencies.limiter ?? PROCESS_UPSTREAM_LIMITER;
155-
156- return async (request) => {
157- const resolution = dependencies.catalog.resolveService(request.serviceId);
158- if (resolution.status !== "RESOLVED") {
159- return publicError("INVALID_SERVICE", nowMilliseconds(dependencies.clock));
160- }
161-
162- const target = buildPrimStopMonitoringUrl(resolution);
163- let apiKey = request.apiKey;
164- let response: unknown;
165- let completedAtMilliseconds: number | undefined;
166- try {
167- response = await limiter.run(async () => {
168- metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_REQUEST, 1);
169- const startedAtMilliseconds = nowMilliseconds(dependencies.clock);
170- let status = 0;
171- try {
172- const relayResponse = await dependencies.relay({ target, apiKey });
173- status = validRelayResponse(relayResponse) ? relayResponse.status : 0;
174- return relayResponse;
175- } finally {
176- completedAtMilliseconds = nowMilliseconds(dependencies.clock);
177- metric(dependencies.metrics, DEPARTURE_METRIC_NAME.UPSTREAM_STATUS, status);
178- metric(
179- dependencies.metrics,
180- DEPARTURE_METRIC_NAME.UPSTREAM_LATENCY_MS,
181- Math.max(0, completedAtMilliseconds - startedAtMilliseconds),
182- );
183- }
184- });
185- } catch {
186- return publicError(
187- "SOURCE_UNAVAILABLE",
188- completedAtMilliseconds ?? nowMilliseconds(dependencies.clock),
189- );
190- } finally {
191- apiKey = "";
192- }
193-
194- const receivedAtMilliseconds =
195- completedAtMilliseconds ?? nowMilliseconds(dependencies.clock);
196- if (!validRelayResponse(response)) {
197- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
198- }
199- if (response.status === 401 || response.status === 403) {
200- return publicError("API_KEY_INVALID", receivedAtMilliseconds);
201- }
202- if (response.status === 429) {
203- return publicError(
204- "RATE_LIMITED",
205- receivedAtMilliseconds,
206- parseRetryAfterSeconds(response.retryAfter),
207- );
208- }
209- if (response.status < 200 || response.status >= 300) {
210- return publicError("SOURCE_UNAVAILABLE", receivedAtMilliseconds);
211- }
212- if (!(response.bytes instanceof Uint8Array)) {
213- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
214- }
215-
216- try {
217- const payload = JSON.parse(STRICT_UTF8_DECODER.decode(response.bytes)) as unknown;
218- return normalizePrimDepartureResponse(
219- payload,
220- resolution,
221- epochSeconds(receivedAtMilliseconds),
222- ) as PublicDepartureData;
223- } catch {
224- return publicError("INVALID_RESPONSE", receivedAtMilliseconds);
225- }
226- };
227-}
deleted packages/backend/src/departures.ts +0 -695
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/src/prim-departures.ts +0 -258
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/test/backend.test.ts +0 -524
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/test/catalog-http.test.ts +0 -158
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/test/departure-endpoint.test.ts +0 -810
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/backend/test/departures.test.ts +0 -1442
Large diff collapsed to keep the page fast — load it when you need it.
renamed packages/catalog/package.json +1 -2
similarity index 75%
rename from packages/backend/package.json
rename to packages/catalog/package.json
@@ -1,9 +1,8 @@
11 {
2- "name": "@lapin-fute/backend",
2+ "name": "@lapin-fute/catalog",
33 "version": "0.1.0",
44 "private": true,
55 "type": "module",
6- "exports": "./src/index.ts",
76 "dependencies": {
87 "csv-parse": "7.0.2",
98 "yauzl": "3.4.0"
similarity index 75%
rename from packages/backend/package.json
rename to packages/catalog/package.json
@@ -1,9 +1,8 @@
1 {1 {
2- "name": "@lapin-fute/backend",2+ "name": "@lapin-fute/catalog",
3 "version": "0.1.0",3 "version": "0.1.0",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6- "exports": "./src/index.ts",
7 "dependencies": {6 "dependencies": {
8 "csv-parse": "7.0.2",7 "csv-parse": "7.0.2",
9 "yauzl": "3.4.0"8 "yauzl": "3.4.0"
renamed packages/catalog/src/catalog-import.ts +175 -100
Large diff collapsed to keep the page fast — load it when you need it.
renamed packages/catalog/src/catalog.ts +21 -106
Large diff collapsed to keep the page fast — load it when you need it.
renamed packages/catalog/src/prim-probe.ts +47 -62
Large diff collapsed to keep the page fast — load it when you need it.
added packages/catalog/src/static-catalog.ts +343 -0
Large diff collapsed to keep the page fast — load it when you need it.
renamed packages/catalog/test/catalog-import.test.ts +220 -2
Large diff collapsed to keep the page fast — load it when you need it.
renamed packages/catalog/test/catalog.test.ts +6 -13
similarity index 97%
rename from packages/backend/test/catalog.test.ts
rename to packages/catalog/test/catalog.test.ts
@@ -223,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
223223
224224 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
225225 metadata.run("schema_version", "1");
226- metadata.run("catalog_version", seed.catalogVersion ?? "2");
226+ metadata.run("catalog_version", seed.catalogVersion ?? "3");
227227 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");
228228 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
229229
@@ -419,7 +419,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
419419 status: "RESOLVED",
420420 monitoringRef: "raw-shared-rer-station",
421421 lineRef: "raw-line-rer-b",
422- directionId: "north",
423422 destinationRef: "terminal-rer-north",
424423 destinationLabel: "Aéroport Charles de Gaulle 2",
425424 });
@@ -427,7 +426,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
427426 status: "RESOLVED",
428427 monitoringRef: "raw-shared-rer-station",
429428 lineRef: "raw-line-rer-b",
430- directionId: "south",
431429 destinationRef: "terminal-rer-south",
432430 destinationLabel: "Saint-Rémy-lès-Chevreuse",
433431 });
@@ -452,7 +450,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
452450 status: "RESOLVED",
453451 monitoringRef: "raw-shared-rer-station",
454452 lineRef: "raw-line-rer-b",
455- directionId: "north",
456453 destinationRef: "terminal-rer-north",
457454 destinationLabel: "Aéroport CDG 2",
458455 });
@@ -461,15 +458,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
461458 { status: "UNRESOLVED", code: "INVALID_SERVICE" },
462459 );
463460 assert.equal(manager.listServices("plc_missing"), undefined);
464- assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [
465- "destinationLabel",
466- "lineColor",
467- "lineLabel",
468- "lineMode",
469- "lineTextColor",
470- "serviceId",
471- "stopLabel",
472- ]);
473461 assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], {
474462 serviceId: replacement.serviceIds["metro-south"],
475463 stopLabel: "Châtelet",
@@ -478,6 +466,11 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
478466 lineMode: "METRO",
479467 lineColor: "#123456",
480468 lineTextColor: "#ffffff",
469+ routing: {
470+ monitoringRef: "raw-monitoring-metro-south",
471+ lineRef: "raw-line-metro-4",
472+ destinationRef: "terminal-metro-south",
473+ },
481474 });
482475 });
483476
similarity index 97%
rename from packages/backend/test/catalog.test.ts
rename to packages/catalog/test/catalog.test.ts
@@ -223,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
223 223
224 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");224 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
225 metadata.run("schema_version", "1");225 metadata.run("schema_version", "1");
226- metadata.run("catalog_version", seed.catalogVersion ?? "2");226+ metadata.run("catalog_version", seed.catalogVersion ?? "3");
227 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");227 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");
228 metadata.run("created_at", "2026-08-30T12:00:00.000Z");228 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
229 229
@@ -419,7 +419,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
419 status: "RESOLVED",419 status: "RESOLVED",
420 monitoringRef: "raw-shared-rer-station",420 monitoringRef: "raw-shared-rer-station",
421 lineRef: "raw-line-rer-b",421 lineRef: "raw-line-rer-b",
422- directionId: "north",
423 destinationRef: "terminal-rer-north",422 destinationRef: "terminal-rer-north",
424 destinationLabel: "Aéroport Charles de Gaulle 2",423 destinationLabel: "Aéroport Charles de Gaulle 2",
425 });424 });
@@ -427,7 +426,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
427 status: "RESOLVED",426 status: "RESOLVED",
428 monitoringRef: "raw-shared-rer-station",427 monitoringRef: "raw-shared-rer-station",
429 lineRef: "raw-line-rer-b",428 lineRef: "raw-line-rer-b",
430- directionId: "south",
431 destinationRef: "terminal-rer-south",429 destinationRef: "terminal-rer-south",
432 destinationLabel: "Saint-Rémy-lès-Chevreuse",430 destinationLabel: "Saint-Rémy-lès-Chevreuse",
433 });431 });
@@ -452,7 +450,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
452 status: "RESOLVED",450 status: "RESOLVED",
453 monitoringRef: "raw-shared-rer-station",451 monitoringRef: "raw-shared-rer-station",
454 lineRef: "raw-line-rer-b",452 lineRef: "raw-line-rer-b",
455- directionId: "north",
456 destinationRef: "terminal-rer-north",453 destinationRef: "terminal-rer-north",
457 destinationLabel: "Aéroport CDG 2",454 destinationLabel: "Aéroport CDG 2",
458 });455 });
@@ -461,15 +458,6 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
461 { status: "UNRESOLVED", code: "INVALID_SERVICE" },458 { status: "UNRESOLVED", code: "INVALID_SERVICE" },
462 );459 );
463 assert.equal(manager.listServices("plc_missing"), undefined);460 assert.equal(manager.listServices("plc_missing"), undefined);
464- assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [
465- "destinationLabel",
466- "lineColor",
467- "lineLabel",
468- "lineMode",
469- "lineTextColor",
470- "serviceId",
471- "stopLabel",
472- ]);
473 assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], {461 assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], {
474 serviceId: replacement.serviceIds["metro-south"],462 serviceId: replacement.serviceIds["metro-south"],
475 stopLabel: "Châtelet",463 stopLabel: "Châtelet",
@@ -478,6 +466,11 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
478 lineMode: "METRO",466 lineMode: "METRO",
479 lineColor: "#123456",467 lineColor: "#123456",
480 lineTextColor: "#ffffff",468 lineTextColor: "#ffffff",
469+ routing: {
470+ monitoringRef: "raw-monitoring-metro-south",
471+ lineRef: "raw-line-metro-4",
472+ destinationRef: "terminal-metro-south",
473+ },
481 });474 });
482 });475 });
483 476
added packages/catalog/test/static-catalog.test.js +216 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/catalog-client.js +155 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/codec.js +133 -129
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/compactparis-time.js +78 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/configuration-sync.js +205 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/configuration.js +379 -135
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/contracts.js +467 -66
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/display-copy.json +237 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/display-font-metrics.json +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+{"sdk":"4.33.1","platforms":["emery","gabbro"],"roles":["14px Gothic","bold 14px Gothic","bold 18px Gothic","bold 36px Gothic"],"resourceKeys":["FONT_KEY_GOTHIC_14","FONT_KEY_GOTHIC_14_BOLD","FONT_KEY_GOTHIC_18_BOLD","FONT_KEY_GOTHIC_36_BOLD"],"heights":[14,14,18,36],"capOffsets":[3,3,3,7],"ascent":[10,10,14,28],"fallback":[7,7,9,18],"groups":[[[0,0,0,0],"🏻🏼🏽🏾🏿"],[[2,4,3,8],"¸"],[[3,3,3,6],"jï"],[[3,3,4,6],"ĵ"],[[3,3,4,8]," , Ï‘’‚∫"],[[3,3,4,10],"Į"],[[3,3,5,10],"ł"],[[3,4,3,6],"ĩīĭļ"],[[3,4,3,7],"į"],[[3,4,4,6],"î"],[[3,4,4,7],"ilìíıĺ"],[[3,4,4,8],"!'.:;I|¡¦·ÍĨİ˙"],[[3,4,4,9],"Ĭ"],[[3,4,5,8],"Ì"],[[3,4,5,9],"Ī"],[[3,4,6,8],"Î"],[[3,6,8,16],"ˆ"],[[4,5,5,10],"[]"],[[4,5,6,12],"{"],[[4,5,7,14],"˛"],[[4,6,6,11],"ftţŧſƒț"],[[4,6,6,12],"r}ŕŗ"],[[4,6,7,11],"ř"],[[4,6,7,13],"J"],[[4,6,7,14],"¯˘"],[[4,6,8,13],"Ĵ"],[[5,4,6,12],"‹›"],[[5,4,7,14],"−"],[[5,5,6,12],"`´¹"],[[5,5,7,14],"-³"],[[5,6,6,12],"()"],[[5,6,7,13],"zźż"],[[5,6,7,14],"²ž“”„"],[[5,6,8,16],"ˇ˜†‡"],[[5,7,7,13],"ŀ"],[[5,7,7,14],"\""],[[5,7,8,16],"?FL¨¿ĹĻĽĿ˝"],[[5,8,8,16],"TŢŤŦȚ"],[[5,8,9,17],"Ł"],[[6,5,8,16],"<>"],[[6,6,6,12],"˚"],[[6,6,7,9],"🎤"],[[6,6,7,14],"°"],[[6,6,8,16],"0123456789§∂∑"],[[6,7,7,13],"sijľśşș"],[[6,7,7,14],"kķĸŝš•"],[[6,7,7,15],"ãñõğũ"],[[6,7,8,14],"øħ"],[[6,7,8,15],"abcdeghnopquxàáâäåçèéêëòóôöùúûüþāăćĉċčēĕėęěĝġģĥńņňʼnŋōŏőūŭůűǿ"],[[6,7,8,16],"/EZ\\¢ªµºÈÉÊËðąđĒĔĖĘĚųŹŻŽ⁄"],[[6,7,9,18],"£π"],[[6,8,7,16],"¤"],[[6,8,8,17],"KĶ"],[[6,8,9,16],"_"],[[6,8,9,17],"ß"],[[6,9,8,15],"ť"],[[6,9,9,17],"fifl"],[[7,7,7,7],"❗"],[[7,7,8,16],"*^¬"],[[7,7,10,20],"÷∆≠"],[[7,8,7,14],"~"],[[7,8,8,15],"vyýÿŷ"],[[7,8,9,17],"Š"],[[7,8,9,18],"$ABCDGHNOPQRSUVXÀÁÂÃÄÅÇÑÒÓÔÕÖØÙÚÛÜÞĀĂĆĈĊČĎĐĜĞĠĢĤĦŃŅŇŊŌŎŐŔŖŘŚŜŞŨŪŬŮŰŲǾȘ"],[[7,8,9,19],"Ą€"],[[7,8,10,19],"Ð"],[[7,8,10,20],"+=Y¥±ÝŶŸ◊"],[[7,9,9,20],"#"],[[7,9,10,20],"IJ"],[[7,9,11,22],"–"],[[8,7,9,18],"«»"],[[8,7,10,20],"≈"],[[8,8,8,16],"¶"],[[8,8,10,20],"∏"],[[8,9,10,20],"&"],[[9,9,10,10],"►"],[[9,9,10,19],"☎"],[[9,10,11,21],"ď"],[[9,10,11,22],"®"],[[9,10,12,23],"mwæœŵǽ"],[[9,10,12,24],"MÆǼ…√"],[[10,10,11,18],"❤💙💚💛💜"],[[10,10,11,25],"♥"],[[10,10,12,24],"@¼"],[[10,10,12,25],"¾"],[[10,11,12,24],"½"],[[10,11,13,26],"Œ"],[[11,10,11,22],"™"],[[11,10,12,24],"∞"],[[11,11,11,16],"🍺"],[[11,11,11,17],"🖤"],[[11,11,11,18],"💔💗💝"],[[11,12,12,24],"WŴ"],[[11,12,14,28],"%"],[[12,12,12,14],"💡"],[[12,12,12,19],"💘"],[[12,12,13,17],"★📷"],[[12,12,13,18],"💖"],[[12,12,13,19],"😾🙁🙂"],[[12,12,13,25],"✈"],[[12,12,13,26],"©"],[[12,13,15,30],"—"],[[13,13,13,14],"☝"],[[13,13,13,15],"✅❎❣🤘"],[[13,13,13,16],"💟"],[[13,13,13,17],"⚧💀"],[[13,13,13,18],"😀😁😃😄😆😇😉😊😋😌😎😏😐😑😒😔😕😖😗😙😚😛😜😝😞😟😠😡😢😣😤😦😧😨😩😪😫😬😭😮😯😱😲😳😴😵😶😷🙃🤣🤤🤪🥺"],[[13,13,13,19],"☺"],[[13,13,13,20],"🌙🌷🌸🌺👀💣"],[[13,13,14,16],"🎥"],[[13,13,17,25],"⌛⌨⏏⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏸⏹⏺▪▫▶◀◻◼◽◾☀☁☂☃☄☑☔☕☘☢☣☦☪☮☯☸☹♀♂♈♉♊♋♌♍♎♏♐♑♒♓♟♠♣♦♨♻♾♿⚒⚓⚔⚕⚖⚗⚙⚛⚜⚠⚡⚪⚫⚰⚱⚽⚾⛄⛅⛈⛎⛏⛑⛓⛔⛩⛪⛰⛱⛲⛳⛴⛵⛷⛸⛹⛺⛽✂✉✍✏✒✔✖✝✡✳✴❄❇❌❓❔❕➕➖➗➡➰➿⬅⬆⬇⬛⬜⭕🅰🅱🅾🅿🆎🆑🆒🆓🆔🆕🆖🆗🆘🆙🆚🈁🈂🈚🈯🈲🈳🈴🈵🈶🈷🈸🈹🈺🉐🉑🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌚🌛🌜🌝🌞🌠🌡🌤🌥🌦🌧🌨🌩🌪🌫🌬🌭🌮🌯🌰🌱🌲🌳🌴🌵🌶🌹🌻🌼🌽🌾🌿🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍠🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵🍶🍷🍸🍹🍼🍽🍾🍿🎀🎂🎃🎄🎅🎆🎇🎈🎊🎋🎌🎍🎎🎏🎐🎑🎒🎓🎖🎗🎙🎚🎛🎞🎟🎠🎡🎢🎣🎦🎧🎨🎩🎪🎫🎬🎭🎮🎯🎰🎱🎲🎳🎴🎷🎸🎹🎺🎻🎼🎽🎾🎿🏀🏁🏂🏃🏄🏅🏆🏇🏈🏉🏊🏋🏌🏍🏎🏏🏐🏑🏒🏓🏔🏕🏖🏗🏘🏙🏚🏛🏜🏝🏞🏟🏠🏡🏢🏣🏤🏥🏦🏧🏨🏩🏪🏫🏬🏭🏮🏯🏰🏴🏵🏷🏸🏹🏺🐀🐁🐂🐃🐄🐅🐆🐇🐈🐉🐊🐋🐌🐍🐎🐏🐐🐑🐒🐓🐔🐕🐖🐗🐘🐙🐚🐛🐜🐝🐞🐟🐠🐡🐢🐣🐤🐦🐧🐨🐩🐪🐫🐬🐭🐮🐯🐰🐱🐲🐳🐴🐶🐷🐸🐹🐺🐻🐼🐽🐾🐿👁👂👃👄👅👑👒👓👔👕👖👗👘👙👚👛👜👝👞👟👠👡👢👣👤👥👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸👹👺👻👼👽👾💁💂💃💄💅💆💇💈💉💊💌💍💎💏💐💑💒💠💢💤💦💧💨💫💬💭💮💰💱💲💳💴💵💶💷💸💹💺💻💼💽💾💿📀📁📂📃📄📅📆📇📈📉📊📋📌📍📎📏📐📑📒📓📔📕📖📗📘📙📚📛📜📝📞📟📠📡📢📣📤📥📦📧📨📩📪📫📬📭📮📯📰📱📲📳📴📵📶📸📹📺📻📼📽📿🔀🔁🔂🔃🔄🔅🔆🔇🔈🔉🔊🔋🔌🔍🔎🔏🔐🔑🔒🔓🔔🔕🔖🔗🔘🔙🔚🔛🔜🔝🔞🔟🔠🔡🔢🔣🔤🔦🔧🔨🔩🔪🔫🔬🔭🔮🔯🔰🔱🔲🔳🔴🔵🔶🔷🔸🔹🔺🔻🔼🔽🕉🕊🕋🕌🕍🕎🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧🕯🕰🕳🕴🕵🕶🕷🕸🕹🕺🖇🖊🖋🖌🖍🖐🖕🖖🖥🖨🖱🖲🖼🗂🗃🗄🗑🗒🗓🗜🗝🗞🗡🗣🗨🗯🗳🗺🗻🗼🗽🗾🗿🙅🙆🙇🙉🙊🙋🙍🙎🚀🚁🚂🚃🚄🚅🚆🚇🚈🚉🚊🚋🚌🚍🚎🚏🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚝🚞🚟🚠🚡🚢🚣🚤🚥🚦🚧🚨🚩🚪🚫🚬🚭🚮🚯🚰🚱🚲🚳🚴🚵🚶🚷🚸🚹🚺🚻🚼🚽🚾🚿🛀🛁🛂🛃🛄🛅🛋🛌🛍🛎🛏🛐🛑🛒🛕🛖🛗🛜🛝🛞🛟🛠🛡🛢🛣🛤🛥🛩🛫🛬🛰🛳🛴🛵🛶🛷🛸🛹🛺🛻🛼🟠🟡🟢🟣🟤🟥🟦🟧🟨🟩🟪🟫🟰🤌🤍🤎🤏🤐🤑🤒🤓🤔🤕🤖🤙🤚🤛🤜🤞🤟🤠🤡🤢🤥🤦🤧🤨🤫🤭🤯🤰🤱🤲🤳🤴🤵🤶🤷🤸🤹🤺🤼🤽🤾🤿🥀🥁🥂🥃🥄🥅🥇🥈🥉🥊🥋🥌🥍🥎🥏🥐🥑🥒🥓🥔🥕🥖🥗🥘🥙🥚🥛🥜🥝🥞🥟🥠🥡🥢🥣🥤🥥🥦🥧🥨🥩🥪🥫🥬🥭🥮🥯🥱🥲🥳🥴🥵🥶🥷🥸🥹🥻🥼🥽🥾🥿🦀🦁🦂🦃🦄🦅🦆🦇🦈🦉🦊🦋🦌🦍🦎🦏🦐🦑🦒🦓🦔🦕🦖🦗🦘🦙🦚🦛🦜🦝🦞🦟🦠🦡🦢🦣🦤🦥🦦🦧🦨🦩🦪🦫🦬🦭🦮🦯🦰🦱🦲🦳🦴🦵🦶🦷🦸🦹🦺🦻🦼🦽🦾🦿🧀🧁🧂🧃🧄🧅🧆🧇🧈🧉🧊🧋🧌🧍🧎🧏🧐🧑🧒🧓🧔🧕🧖🧗🧘🧙🧚🧛🧜🧝🧞🧟🧠🧡🧢🧣🧤🧥🧦🧧🧨🧩🧪🧫🧬🧭🧮🧯🧰🧱🧲🧳🧴🧵🧶🧷🧸🧹🧺🧻🧼🧽🧾🧿🩰🩱🩲🩳🩴🩵🩶🩷🩸🩹🩺🩻🩼🪀🪁🪂🪃🪄🪅🪆🪇🪈🪐🪑🪒🪓🪔🪕🪖🪗🪘🪙🪚🪛🪜🪝🪞🪟🪠🪡🪢🪣🪤🪥🪦🪧🪨🪩🪪🪫🪬🪭🪮🪯🪰🪱🪲🪳🪴🪵🪶🪷🪸🪹🪺🪻🪼🪽🪿🫀🫁🫂🫃🫄🫅🫎🫏🫐🫑🫒🫓🫔🫕🫖🫗🫘🫙🫚🫛🫠🫡🫢🫣🫤🫥🫦🫧🫨🫰🫱🫲🫳🫴🫵🫶🫷🫸"],[[14,14,14,15],"✊✋✌"],[[14,14,14,17],"🐥💯"],[[14,14,14,18],"🎵"],[[14,14,14,19],"😸😺😻😼😽🙀"],[[14,14,14,20],"😿"],[[14,14,14,21],"✨"],[[14,14,14,22],"😹"],[[14,14,15,15],"→"],[[14,15,19,38],"‰"],[[15,15,15,13],"👊"],[[15,15,15,16],"👆👇"],[[15,15,15,17],"🍀"],[[15,15,15,18],"🙄"],[[15,15,15,19],"🎁💕💞"],[[15,15,15,22],"😂🤝"],[[15,15,16,20],"🐵🙈"],[[16,16,16,17],"👌"],[[16,16,16,18],"🇦🇧🇨🇩🇪🇫🇬🇭🇮🇯🇰🇱🇲🇳🇴🇵🇶🇷🇸🇹🇺🇻🇼🇽🇾🇿🏳"],[[16,16,16,19],"💩🔥"],[[16,16,16,20],"😅😓😥😰"],[[16,16,16,21],"⭐🌟😘"],[[17,17,17,18],"💪🤮"],[[17,17,17,20],"😈🤗🤬"],[[17,17,17,21],"🎉👿💥"],[[17,17,17,22],"😍🙏🤩"],[[17,17,17,23],"☠"],[[18,18,18,19],"🎶👈👉👋👍👎"],[[19,19,19,19],"⌚"],[[19,19,19,20],"🍻💓"],[[19,19,19,22],"🥰"],[[20,20,20,21],"👏💋"],[[23,23,23,24],"🙌"],[[27,27,27,28],"👐"]],"note":"Exact native single-codepoint widths. Whole string shaping/control/layout rules are separate, not globally additive."}
\ No newline at end of file
new file mode 100644
@@ -0,0 +1 @@
1+{"sdk":"4.33.1","platforms":["emery","gabbro"],"roles":["14px Gothic","bold 14px Gothic","bold 18px Gothic","bold 36px Gothic"],"resourceKeys":["FONT_KEY_GOTHIC_14","FONT_KEY_GOTHIC_14_BOLD","FONT_KEY_GOTHIC_18_BOLD","FONT_KEY_GOTHIC_36_BOLD"],"heights":[14,14,18,36],"capOffsets":[3,3,3,7],"ascent":[10,10,14,28],"fallback":[7,7,9,18],"groups":[[[0,0,0,0],"🏻🏼🏽🏾🏿"],[[2,4,3,8],"¸"],[[3,3,3,6],"jï"],[[3,3,4,6],"ĵ"],[[3,3,4,8]," , Ï‘’‚∫"],[[3,3,4,10],"Į"],[[3,3,5,10],"ł"],[[3,4,3,6],"ĩīĭļ"],[[3,4,3,7],"į"],[[3,4,4,6],"î"],[[3,4,4,7],"ilìíıĺ"],[[3,4,4,8],"!'.:;I|¡¦·ÍĨİ˙"],[[3,4,4,9],"Ĭ"],[[3,4,5,8],"Ì"],[[3,4,5,9],"Ī"],[[3,4,6,8],"Î"],[[3,6,8,16],"ˆ"],[[4,5,5,10],"[]"],[[4,5,6,12],"{"],[[4,5,7,14],"˛"],[[4,6,6,11],"ftţŧſƒț"],[[4,6,6,12],"r}ŕŗ"],[[4,6,7,11],"ř"],[[4,6,7,13],"J"],[[4,6,7,14],"¯˘"],[[4,6,8,13],"Ĵ"],[[5,4,6,12],"‹›"],[[5,4,7,14],"−"],[[5,5,6,12],"`´¹"],[[5,5,7,14],"-³"],[[5,6,6,12],"()"],[[5,6,7,13],"zźż"],[[5,6,7,14],"²ž“”„"],[[5,6,8,16],"ˇ˜†‡"],[[5,7,7,13],"ŀ"],[[5,7,7,14],"\""],[[5,7,8,16],"?FL¨¿ĹĻĽĿ˝"],[[5,8,8,16],"TŢŤŦȚ"],[[5,8,9,17],"Ł"],[[6,5,8,16],"<>"],[[6,6,6,12],"˚"],[[6,6,7,9],"🎤"],[[6,6,7,14],"°"],[[6,6,8,16],"0123456789§∂∑"],[[6,7,7,13],"sijľśşș"],[[6,7,7,14],"kķĸŝš•"],[[6,7,7,15],"ãñõğũ"],[[6,7,8,14],"øħ"],[[6,7,8,15],"abcdeghnopquxàáâäåçèéêëòóôöùúûüþāăćĉċčēĕėęěĝġģĥńņňʼnŋōŏőūŭůűǿ"],[[6,7,8,16],"/EZ\\¢ªµºÈÉÊËðąđĒĔĖĘĚųŹŻŽ⁄"],[[6,7,9,18],"£π"],[[6,8,7,16],"¤"],[[6,8,8,17],"KĶ"],[[6,8,9,16],"_"],[[6,8,9,17],"ß"],[[6,9,8,15],"ť"],[[6,9,9,17],"fifl"],[[7,7,7,7],"❗"],[[7,7,8,16],"*^¬"],[[7,7,10,20],"÷∆≠"],[[7,8,7,14],"~"],[[7,8,8,15],"vyýÿŷ"],[[7,8,9,17],"Š"],[[7,8,9,18],"$ABCDGHNOPQRSUVXÀÁÂÃÄÅÇÑÒÓÔÕÖØÙÚÛÜÞĀĂĆĈĊČĎĐĜĞĠĢĤĦŃŅŇŊŌŎŐŔŖŘŚŜŞŨŪŬŮŰŲǾȘ"],[[7,8,9,19],"Ą€"],[[7,8,10,19],"Ð"],[[7,8,10,20],"+=Y¥±ÝŶŸ◊"],[[7,9,9,20],"#"],[[7,9,10,20],"IJ"],[[7,9,11,22],"–"],[[8,7,9,18],"«»"],[[8,7,10,20],"≈"],[[8,8,8,16],"¶"],[[8,8,10,20],"∏"],[[8,9,10,20],"&"],[[9,9,10,10],"►"],[[9,9,10,19],"☎"],[[9,10,11,21],"ď"],[[9,10,11,22],"®"],[[9,10,12,23],"mwæœŵǽ"],[[9,10,12,24],"MÆǼ…√"],[[10,10,11,18],"❤💙💚💛💜"],[[10,10,11,25],"♥"],[[10,10,12,24],"@¼"],[[10,10,12,25],"¾"],[[10,11,12,24],"½"],[[10,11,13,26],"Œ"],[[11,10,11,22],"™"],[[11,10,12,24],"∞"],[[11,11,11,16],"🍺"],[[11,11,11,17],"🖤"],[[11,11,11,18],"💔💗💝"],[[11,12,12,24],"WŴ"],[[11,12,14,28],"%"],[[12,12,12,14],"💡"],[[12,12,12,19],"💘"],[[12,12,13,17],"★📷"],[[12,12,13,18],"💖"],[[12,12,13,19],"😾🙁🙂"],[[12,12,13,25],"✈"],[[12,12,13,26],"©"],[[12,13,15,30],"—"],[[13,13,13,14],"☝"],[[13,13,13,15],"✅❎❣🤘"],[[13,13,13,16],"💟"],[[13,13,13,17],"⚧💀"],[[13,13,13,18],"😀😁😃😄😆😇😉😊😋😌😎😏😐😑😒😔😕😖😗😙😚😛😜😝😞😟😠😡😢😣😤😦😧😨😩😪😫😬😭😮😯😱😲😳😴😵😶😷🙃🤣🤤🤪🥺"],[[13,13,13,19],"☺"],[[13,13,13,20],"🌙🌷🌸🌺👀💣"],[[13,13,14,16],"🎥"],[[13,13,17,25],"⌛⌨⏏⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏸⏹⏺▪▫▶◀◻◼◽◾☀☁☂☃☄☑☔☕☘☢☣☦☪☮☯☸☹♀♂♈♉♊♋♌♍♎♏♐♑♒♓♟♠♣♦♨♻♾♿⚒⚓⚔⚕⚖⚗⚙⚛⚜⚠⚡⚪⚫⚰⚱⚽⚾⛄⛅⛈⛎⛏⛑⛓⛔⛩⛪⛰⛱⛲⛳⛴⛵⛷⛸⛹⛺⛽✂✉✍✏✒✔✖✝✡✳✴❄❇❌❓❔❕➕➖➗➡➰➿⬅⬆⬇⬛⬜⭕🅰🅱🅾🅿🆎🆑🆒🆓🆔🆕🆖🆗🆘🆙🆚🈁🈂🈚🈯🈲🈳🈴🈵🈶🈷🈸🈹🈺🉐🉑🌀🌁🌂🌃🌄🌅🌆🌇🌈🌉🌊🌋🌌🌍🌎🌏🌐🌑🌒🌓🌔🌕🌖🌗🌘🌚🌛🌜🌝🌞🌠🌡🌤🌥🌦🌧🌨🌩🌪🌫🌬🌭🌮🌯🌰🌱🌲🌳🌴🌵🌶🌹🌻🌼🌽🌾🌿🍁🍂🍃🍄🍅🍆🍇🍈🍉🍊🍋🍌🍍🍎🍏🍐🍑🍒🍓🍔🍕🍖🍗🍘🍙🍚🍛🍜🍝🍞🍟🍠🍡🍢🍣🍤🍥🍦🍧🍨🍩🍪🍫🍬🍭🍮🍯🍰🍱🍲🍳🍴🍵🍶🍷🍸🍹🍼🍽🍾🍿🎀🎂🎃🎄🎅🎆🎇🎈🎊🎋🎌🎍🎎🎏🎐🎑🎒🎓🎖🎗🎙🎚🎛🎞🎟🎠🎡🎢🎣🎦🎧🎨🎩🎪🎫🎬🎭🎮🎯🎰🎱🎲🎳🎴🎷🎸🎹🎺🎻🎼🎽🎾🎿🏀🏁🏂🏃🏄🏅🏆🏇🏈🏉🏊🏋🏌🏍🏎🏏🏐🏑🏒🏓🏔🏕🏖🏗🏘🏙🏚🏛🏜🏝🏞🏟🏠🏡🏢🏣🏤🏥🏦🏧🏨🏩🏪🏫🏬🏭🏮🏯🏰🏴🏵🏷🏸🏹🏺🐀🐁🐂🐃🐄🐅🐆🐇🐈🐉🐊🐋🐌🐍🐎🐏🐐🐑🐒🐓🐔🐕🐖🐗🐘🐙🐚🐛🐜🐝🐞🐟🐠🐡🐢🐣🐤🐦🐧🐨🐩🐪🐫🐬🐭🐮🐯🐰🐱🐲🐳🐴🐶🐷🐸🐹🐺🐻🐼🐽🐾🐿👁👂👃👄👅👑👒👓👔👕👖👗👘👙👚👛👜👝👞👟👠👡👢👣👤👥👦👧👨👩👪👫👬👭👮👯👰👱👲👳👴👵👶👷👸👹👺👻👼👽👾💁💂💃💄💅💆💇💈💉💊💌💍💎💏💐💑💒💠💢💤💦💧💨💫💬💭💮💰💱💲💳💴💵💶💷💸💹💺💻💼💽💾💿📀📁📂📃📄📅📆📇📈📉📊📋📌📍📎📏📐📑📒📓📔📕📖📗📘📙📚📛📜📝📞📟📠📡📢📣📤📥📦📧📨📩📪📫📬📭📮📯📰📱📲📳📴📵📶📸📹📺📻📼📽📿🔀🔁🔂🔃🔄🔅🔆🔇🔈🔉🔊🔋🔌🔍🔎🔏🔐🔑🔒🔓🔔🔕🔖🔗🔘🔙🔚🔛🔜🔝🔞🔟🔠🔡🔢🔣🔤🔦🔧🔨🔩🔪🔫🔬🔭🔮🔯🔰🔱🔲🔳🔴🔵🔶🔷🔸🔹🔺🔻🔼🔽🕉🕊🕋🕌🕍🕎🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛🕜🕝🕞🕟🕠🕡🕢🕣🕤🕥🕦🕧🕯🕰🕳🕴🕵🕶🕷🕸🕹🕺🖇🖊🖋🖌🖍🖐🖕🖖🖥🖨🖱🖲🖼🗂🗃🗄🗑🗒🗓🗜🗝🗞🗡🗣🗨🗯🗳🗺🗻🗼🗽🗾🗿🙅🙆🙇🙉🙊🙋🙍🙎🚀🚁🚂🚃🚄🚅🚆🚇🚈🚉🚊🚋🚌🚍🚎🚏🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚝🚞🚟🚠🚡🚢🚣🚤🚥🚦🚧🚨🚩🚪🚫🚬🚭🚮🚯🚰🚱🚲🚳🚴🚵🚶🚷🚸🚹🚺🚻🚼🚽🚾🚿🛀🛁🛂🛃🛄🛅🛋🛌🛍🛎🛏🛐🛑🛒🛕🛖🛗🛜🛝🛞🛟🛠🛡🛢🛣🛤🛥🛩🛫🛬🛰🛳🛴🛵🛶🛷🛸🛹🛺🛻🛼🟠🟡🟢🟣🟤🟥🟦🟧🟨🟩🟪🟫🟰🤌🤍🤎🤏🤐🤑🤒🤓🤔🤕🤖🤙🤚🤛🤜🤞🤟🤠🤡🤢🤥🤦🤧🤨🤫🤭🤯🤰🤱🤲🤳🤴🤵🤶🤷🤸🤹🤺🤼🤽🤾🤿🥀🥁🥂🥃🥄🥅🥇🥈🥉🥊🥋🥌🥍🥎🥏🥐🥑🥒🥓🥔🥕🥖🥗🥘🥙🥚🥛🥜🥝🥞🥟🥠🥡🥢🥣🥤🥥🥦🥧🥨🥩🥪🥫🥬🥭🥮🥯🥱🥲🥳🥴🥵🥶🥷🥸🥹🥻🥼🥽🥾🥿🦀🦁🦂🦃🦄🦅🦆🦇🦈🦉🦊🦋🦌🦍🦎🦏🦐🦑🦒🦓🦔🦕🦖🦗🦘🦙🦚🦛🦜🦝🦞🦟🦠🦡🦢🦣🦤🦥🦦🦧🦨🦩🦪🦫🦬🦭🦮🦯🦰🦱🦲🦳🦴🦵🦶🦷🦸🦹🦺🦻🦼🦽🦾🦿🧀🧁🧂🧃🧄🧅🧆🧇🧈🧉🧊🧋🧌🧍🧎🧏🧐🧑🧒🧓🧔🧕🧖🧗🧘🧙🧚🧛🧜🧝🧞🧟🧠🧡🧢🧣🧤🧥🧦🧧🧨🧩🧪🧫🧬🧭🧮🧯🧰🧱🧲🧳🧴🧵🧶🧷🧸🧹🧺🧻🧼🧽🧾🧿🩰🩱🩲🩳🩴🩵🩶🩷🩸🩹🩺🩻🩼🪀🪁🪂🪃🪄🪅🪆🪇🪈🪐🪑🪒🪓🪔🪕🪖🪗🪘🪙🪚🪛🪜🪝🪞🪟🪠🪡🪢🪣🪤🪥🪦🪧🪨🪩🪪🪫🪬🪭🪮🪯🪰🪱🪲🪳🪴🪵🪶🪷🪸🪹🪺🪻🪼🪽🪿🫀🫁🫂🫃🫄🫅🫎🫏🫐🫑🫒🫓🫔🫕🫖🫗🫘🫙🫚🫛🫠🫡🫢🫣🫤🫥🫦🫧🫨🫰🫱🫲🫳🫴🫵🫶🫷🫸"],[[14,14,14,15],"✊✋✌"],[[14,14,14,17],"🐥💯"],[[14,14,14,18],"🎵"],[[14,14,14,19],"😸😺😻😼😽🙀"],[[14,14,14,20],"😿"],[[14,14,14,21],"✨"],[[14,14,14,22],"😹"],[[14,14,15,15],"→"],[[14,15,19,38],"‰"],[[15,15,15,13],"👊"],[[15,15,15,16],"👆👇"],[[15,15,15,17],"🍀"],[[15,15,15,18],"🙄"],[[15,15,15,19],"🎁💕💞"],[[15,15,15,22],"😂🤝"],[[15,15,16,20],"🐵🙈"],[[16,16,16,17],"👌"],[[16,16,16,18],"🇦🇧🇨🇩🇪🇫🇬🇭🇮🇯🇰🇱🇲🇳🇴🇵🇶🇷🇸🇹🇺🇻🇼🇽🇾🇿🏳"],[[16,16,16,19],"💩🔥"],[[16,16,16,20],"😅😓😥😰"],[[16,16,16,21],"⭐🌟😘"],[[17,17,17,18],"💪🤮"],[[17,17,17,20],"😈🤗🤬"],[[17,17,17,21],"🎉👿💥"],[[17,17,17,22],"😍🙏🤩"],[[17,17,17,23],"☠"],[[18,18,18,19],"🎶👈👉👋👍👎"],[[19,19,19,19],"⌚"],[[19,19,19,20],"🍻💓"],[[19,19,19,22],"🥰"],[[20,20,20,21],"👏💋"],[[23,23,23,24],"🙌"],[[27,27,27,28],"👐"]],"note":"Exact native single-codepoint widths. Whole string shaping/control/layout rules are separate, not globally additive."}
\ No newline at end of file\ No newline at end of file
added packages/companion/src/display-fonts.js +77 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/display-layout.js +221 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/display.js +117 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/index.js +842 -435
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/message-queue.js +89 -98
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/prim-client.js +432 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/prim-departures.js +215 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/src/prim-traffic.js +314 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/companion.test.js +675 -1209
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/fakes.js +12 -2
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/test/prim-client.test.js +476 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/test/prim-normalization.test.js +351 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/companion/test/prim-traffic-empty-periods.test.js +113 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/protocol.test.js +182 -233
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/index.html +12 -0
@@ -71,6 +71,18 @@
7171 <button type="button" id="favorite-add" class="secondary"></button>
7272 </div>
7373 </section>
74+
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>
79+ </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>
7486 </div>
7587
7688 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
@@ -71,6 +71,18 @@
71 <button type="button" id="favorite-add" class="secondary"></button>71 <button type="button" id="favorite-add" class="secondary"></button>
72 </div>72 </div>
73 </section>73 </section>
74+
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>
79+ </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>
74 </div>86 </div>
75 87
76 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>88 <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
modified packages/config-page/src/catalog-client.js +192 -35
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/src/config-core.js +131 -20
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/src/config-page.js +53 -1
Large diff collapsed to keep the page fast — load it when you need it.
added packages/config-page/src/search-text.js +15 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/styles/config-page.css +19 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/config-page/test/catalog-client.test.js +212 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/config-page/test/config-page.test.ts +366 -80
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/src/index.ts +563 -371
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/contracts/test/contracts.test.ts +571 -107
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/.lock-waf_linux_build +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/package.json +12 -18
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/prototype-issue-16.html +681 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/c/mdbl.c +3 -3
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/contracts.js +0 -139
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/controller.js +0 -236
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/localization.js +0 -69
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/main.js +34 -80
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/manifest.json +2 -14
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/message-queue.js +0 -96
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/model.js +0 -246
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/src/embeddedjs/packed.js +192 -0
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/presentation.js +0 -108
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/src/embeddedjs/protocol.js +0 -632
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/src/embeddedjs/runtime.js +350 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/storage.js +104 -280
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/ui.js +208 -316
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/controller-storage.test.js +0 -692
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/d2-records.js +212 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/display-copy.test.js +49 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/fixture-boot.test.js +231 -508
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/localization.test.js +0 -84
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/main.test.js +92 -0
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/manifest.test.js +0 -181
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/message-queue.test.js +0 -118
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/model.test.js +0 -397
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/native-host.js +124 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/packed.test.js +135 -0
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/presentation.test.js +0 -527
Large diff collapsed to keep the page fast — load it when you need it.
deleted packages/watch/test/protocol.test.js +0 -437
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/runtime.test.js +626 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/storage.test.js +162 -105
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/ui.test.js +122 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/xs-host.js +31 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/build-config-site.mjs +70 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/build.mjs +11 -8
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/bundle-watch.mjs +20 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/generate-display-copy.mjs +76 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/generate-paris-time.mjs +91 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/measure-catalog.mjs +3 -2
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/measure-radio.mjs +114 -369
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/probe-catalog.mjs +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/refresh-catalog.mjs +33 -5
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/test.mjs +20 -23
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/verify-watch-build.mjs +13 -16
Large diff collapsed to keep the page fast — load it when you need it.