anthodev/lapin-futepublic Fork
⑂ f7d01bd
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: build companion configuration page (#13)Unverified

Closes #5
Cédric Anthony committed 2026-09-01T20:49:03+02:00 Browse files
f7d01bd parent: 5a0b437
modified fixtures/catalog/idfm-v1.json +6 -6
@@ -22,12 +22,12 @@
2222 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
2323 ],
2424 "lines": [
25- { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" },
26- { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" },
27- { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" },
28- { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" },
29- { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" },
30- { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" }
25+ { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active", "colourweb_hexa": "E86A10", "textcolourweb_hexa": "FFFFFF" },
26+ { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active", "colourweb_hexa": "#BE418D", "textcolourweb_hexa": "#FFFFFF" },
27+ { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active", "colourweb_hexa": "85216F", "textcolourweb_hexa": "FFFFFF" },
28+ { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active", "colourweb_hexa": "E3051C", "textcolourweb_hexa": "FFFFFF" },
29+ { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active", "colourweb_hexa": "7584BC", "textcolourweb_hexa": "000000" },
30+ { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active", "colourweb_hexa": "000000", "textcolourweb_hexa": "FFFFFF" }
3131 ],
3232 "routes": [
3333 { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" },
@@ -22,12 +22,12 @@
22 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }22 { "agency_name": "Fixture IDFM operator", "agency_url": "https://www.iledefrance-mobilites.fr/", "agency_timezone": "Europe/Paris" }
23 ],23 ],
24 "lines": [24 "lines": [
25- { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active" },25+ { "id_line": "C100", "name_line": "Bus 42", "shortname_line": "42", "transportmode": "bus", "transportsubmode": "", "status": "active", "colourweb_hexa": "E86A10", "textcolourweb_hexa": "FFFFFF" },
26- { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active" },26+ { "id_line": "C200", "name_line": "Métro 4", "shortname_line": "4", "transportmode": "metro", "transportsubmode": "", "status": "active", "colourweb_hexa": "#BE418D", "textcolourweb_hexa": "#FFFFFF" },
27- { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active" },27+ { "id_line": "C300", "name_line": "Tram T3a", "shortname_line": "T3a", "transportmode": "tram", "transportsubmode": "", "status": "active", "colourweb_hexa": "85216F", "textcolourweb_hexa": "FFFFFF" },
28- { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active" },28+ { "id_line": "C400", "name_line": "RER A", "shortname_line": "A", "transportmode": "rail", "transportsubmode": "local", "status": "active", "colourweb_hexa": "E3051C", "textcolourweb_hexa": "FFFFFF" },
29- { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active" },29+ { "id_line": "C500", "name_line": "Transilien L", "shortname_line": "L", "transportmode": "rail", "transportsubmode": "suburbanRailway", "status": "active", "colourweb_hexa": "7584BC", "textcolourweb_hexa": "000000" },
30- { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active" }30+ { "id_line": "C600", "name_line": "Funiculaire", "shortname_line": "F", "transportmode": "funicular", "transportsubmode": "", "status": "active", "colourweb_hexa": "000000", "textcolourweb_hexa": "FFFFFF" }
31 ],31 ],
32 "routes": [32 "routes": [
33 { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" },33 { "route_id": "IDFM:C100", "route_short_name": "42", "route_long_name": "Bus 42" },
modified fixtures/departures/foundation.json +3 -0
@@ -7,6 +7,9 @@
77 "stopLabel": "Châtelet",
88 "lineLabel": "Métro 1",
99 "destinationLabel": "La Défense",
10+ "lineMode": "METRO",
11+ "lineColor": "#ffbe00",
12+ "lineTextColor": "#000000",
1013 "sortOrder": 0
1114 },
1215 "result": {
@@ -7,6 +7,9 @@
7 "stopLabel": "Châtelet",7 "stopLabel": "Châtelet",
8 "lineLabel": "Métro 1",8 "lineLabel": "Métro 1",
9 "destinationLabel": "La Défense",9 "destinationLabel": "La Défense",
10+ "lineMode": "METRO",
11+ "lineColor": "#ffbe00",
12+ "lineTextColor": "#000000",
10 "sortOrder": 013 "sortOrder": 0
11 },14 },
12 "result": {15 "result": {
modified package.json +2 -0
@@ -13,8 +13,10 @@
1313 "build": "node scripts/build.mjs",
1414 "catalog:probe": "node scripts/probe-catalog.mjs",
1515 "catalog:refresh": "node scripts/refresh-catalog.mjs",
16+ "badges:sync": "node scripts/sync-line-badges.mjs",
1617 "measure:radio": "node scripts/measure-radio.mjs",
1718 "measure:catalog": "node scripts/measure-catalog.mjs",
19+ "measure:config-page": "node scripts/measure-config-page.mjs",
1820 "test": "node scripts/test.mjs",
1921 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
2022 "test:watch": "node --test packages/watch/test/*.test.js",
@@ -13,8 +13,10 @@
13 "build": "node scripts/build.mjs",13 "build": "node scripts/build.mjs",
14 "catalog:probe": "node scripts/probe-catalog.mjs",14 "catalog:probe": "node scripts/probe-catalog.mjs",
15 "catalog:refresh": "node scripts/refresh-catalog.mjs",15 "catalog:refresh": "node scripts/refresh-catalog.mjs",
16+ "badges:sync": "node scripts/sync-line-badges.mjs",
16 "measure:radio": "node scripts/measure-radio.mjs",17 "measure:radio": "node scripts/measure-radio.mjs",
17 "measure:catalog": "node scripts/measure-catalog.mjs",18 "measure:catalog": "node scripts/measure-catalog.mjs",
19+ "measure:config-page": "node scripts/measure-config-page.mjs",
18 "test": "node scripts/test.mjs",20 "test": "node scripts/test.mjs",
19 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",21 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
20 "test:watch": "node --test packages/watch/test/*.test.js",22 "test:watch": "node --test packages/watch/test/*.test.js",
modified packages/backend/src/catalog-import.ts +35 -12
@@ -24,7 +24,7 @@ import {
2424 } from "../../contracts/src/index.ts";
2525 import type { TransportMode } from "../../contracts/src/index.ts";
2626
27-export const CATALOG_VERSION = 1 as const;
27+export const CATALOG_VERSION = 2 as const;
2828 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;
2929 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;
3030 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;
@@ -224,6 +224,12 @@ function canonicalLineId(value: unknown, field: string): string {
224224 return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text;
225225 }
226226
227+function normalizeLineColor(value: unknown, field: string): string {
228+ const text = nonEmpty(value, field);
229+ if (!/^#?[0-9a-f]{6}$/iu.test(text)) fail("INVALID_SOURCE", `${field} must be a six-digit hexadecimal color`);
230+ return `#${text.replace(/^#/u, "").toLowerCase()}`;
231+}
232+
227233 function isoTimestamp(value: string, field: string): string {
228234 if (value.length === 0 || !Number.isFinite(Date.parse(value))) {
229235 fail("INVALID_METADATA", `${field} must be an ISO timestamp`);
@@ -500,6 +506,8 @@ function createSchema(database: DatabaseSync): void {
500506 stop_label TEXT NOT NULL,
501507 line_label TEXT NOT NULL,
502508 destination_label TEXT NOT NULL,
509+ line_color TEXT NOT NULL,
510+ line_text_color TEXT NOT NULL,
503511 monitoring_ref TEXT NOT NULL,
504512 line_ref TEXT NOT NULL,
505513 direction_id TEXT NOT NULL,
@@ -575,7 +583,9 @@ function createSchema(database: DatabaseSync): void {
575583 shortname_line TEXT NOT NULL,
576584 transport_mode TEXT NOT NULL,
577585 transport_submode TEXT NOT NULL,
578- status TEXT NOT NULL
586+ status TEXT NOT NULL,
587+ line_color TEXT NOT NULL,
588+ line_text_color TEXT NOT NULL
579589 ) STRICT;
580590 CREATE TABLE stage_arrets_lignes (
581591 route_id TEXT NOT NULL,
@@ -596,7 +606,7 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
596606 const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)");
597607 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");
598608 const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)");
599- const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)");
609+ const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
600610 const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)");
601611
602612 return {
@@ -649,8 +659,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
649659 insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")),
650660 },
651661 lines: {
652- required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"],
653- insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")),
662+ required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"],
663+ insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status"), normalizeLineColor(row.colourweb_hexa, "lines.colourweb_hexa"), normalizeLineColor(row.textcolourweb_hexa, "lines.textcolourweb_hexa")),
654664 },
655665 arretsLignes: {
656666 required: ["id", "stop_id"],
@@ -959,7 +969,9 @@ function deriveCandidates(database: DatabaseSync): void {
959969 CREATE TABLE stage_line_modes (
960970 id_line TEXT PRIMARY KEY,
961971 mode TEXT NOT NULL,
962- line_label TEXT NOT NULL
972+ line_label TEXT NOT NULL,
973+ line_color TEXT NOT NULL,
974+ line_text_color TEXT NOT NULL
963975 ) STRICT;
964976
965977 INSERT INTO stage_line_modes
@@ -971,7 +983,9 @@ function deriveCandidates(database: DatabaseSync): void {
971983 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER'
972984 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN'
973985 END,
974- coalesce(nullif(trim(shortname_line), ''), trim(name_line))
986+ coalesce(nullif(trim(shortname_line), ''), trim(name_line)),
987+ line_color,
988+ line_text_color
975989 FROM stage_lines
976990 WHERE lower(trim(status)) = 'active'
977991 AND (
@@ -1040,6 +1054,8 @@ function deriveCandidates(database: DatabaseSync): void {
10401054 zone.zdatown AS locality_label,
10411055 line_mode.id_line AS id_line,
10421056 line_mode.line_label AS line_label,
1057+ line_mode.line_color AS line_color,
1058+ line_mode.line_text_color AS line_text_color,
10431059 CASE
10441060 WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name
10451061 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name)
@@ -1089,7 +1105,8 @@ function deriveCandidates(database: DatabaseSync): void {
10891105 AND trim(destination_code.object_code) <> ''
10901106 )
10911107 SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label,
1092- id_line, min(line_label) AS line_label, min(destination_label) AS destination_label,
1108+ id_line, min(line_label) AS line_label, min(line_color) AS line_color,
1109+ min(line_text_color) AS line_text_color, min(destination_label) AS destination_label,
10931110 monitoring_ref, line_ref, direction_id, destination_ref,
10941111 max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets
10951112 FROM joined
@@ -1107,7 +1124,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
11071124
11081125 const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
11091126 const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
1110- const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
1127+ const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
11111128 const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?");
11121129 const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?");
11131130
@@ -1146,7 +1163,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
11461163
11471164 const candidates = database.prepare(`
11481165 SELECT mode, zdaid, stop_label, line_label, destination_label,
1149- id_line, monitoring_ref, line_ref, direction_id, destination_ref
1166+ line_color, line_text_color, id_line, monitoring_ref, line_ref, direction_id, destination_ref
11501167 FROM stage_candidates
11511168 ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref
11521169 `).iterate() as Iterable<QueryRow>;
@@ -1174,6 +1191,8 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
11741191 nonEmpty(row.stop_label, "candidate.stop_label"),
11751192 nonEmpty(row.line_label, "candidate.line_label"),
11761193 nonEmpty(row.destination_label, "candidate.destination_label"),
1194+ normalizeLineColor(row.line_color, "candidate.line_color"),
1195+ normalizeLineColor(row.line_text_color, "candidate.line_text_color"),
11771196 nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"),
11781197 nonEmpty(row.line_ref, "candidate.line_ref"),
11791198 nonEmpty(row.direction_id, "candidate.direction_id"),
@@ -1206,6 +1225,8 @@ function validateByteBounds(database: DatabaseSync): void {
12061225 OR length(CAST(stop_label AS BLOB)) > ?
12071226 OR length(CAST(line_label AS BLOB)) > ?
12081227 OR length(CAST(destination_label AS BLOB)) > ?
1228+ OR length(CAST(line_color AS BLOB)) <> 7
1229+ OR length(CAST(line_text_color AS BLOB)) <> 7
12091230 LIMIT 1
12101231 `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes);
12111232 if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit");
@@ -1228,7 +1249,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
12281249 database.exec("PRAGMA foreign_keys = ON");
12291250 exactColumns(database, "metadata", ["key", "value"]);
12301251 exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]);
1231- exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]);
1252+ exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "line_color", "line_text_color", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]);
12321253 exactColumns(database, "place_search", ["search_text", "place_id"]);
12331254
12341255 const sourceRevision = metadataValue(database, "source_revision");
@@ -1276,9 +1297,11 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
12761297 const unresolved = database.prepare(`
12771298 SELECT 1 FROM services
12781299 WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = ''
1300+ OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
1301+ OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
12791302 LIMIT 1
12801303 `).get();
1281- if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service");
1304+ if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable or malformed offered service");
12821305
12831306 for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) {
12841307 if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple");
@@ -24,7 +24,7 @@ import {
24 } from "../../contracts/src/index.ts";24 } from "../../contracts/src/index.ts";
25 import type { TransportMode } from "../../contracts/src/index.ts";25 import type { TransportMode } from "../../contracts/src/index.ts";
26 26
27-export const CATALOG_VERSION = 1 as const;27+export const CATALOG_VERSION = 2 as const;
28 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;28 export const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1_000;
29 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;29 export const MAX_COMPRESSED_DOWNLOAD_BYTES = 2 * 1024 ** 3;
30 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;30 export const MAX_SELECTED_ENTRY_BYTES = 8 * 1024 ** 3;
@@ -224,6 +224,12 @@ function canonicalLineId(value: unknown, field: string): string {
224 return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text;224 return text.startsWith("IDFM:") ? text.slice("IDFM:".length) : text;
225 }225 }
226 226
227+function normalizeLineColor(value: unknown, field: string): string {
228+ const text = nonEmpty(value, field);
229+ if (!/^#?[0-9a-f]{6}$/iu.test(text)) fail("INVALID_SOURCE", `${field} must be a six-digit hexadecimal color`);
230+ return `#${text.replace(/^#/u, "").toLowerCase()}`;
231+}
232+
227 function isoTimestamp(value: string, field: string): string {233 function isoTimestamp(value: string, field: string): string {
228 if (value.length === 0 || !Number.isFinite(Date.parse(value))) {234 if (value.length === 0 || !Number.isFinite(Date.parse(value))) {
229 fail("INVALID_METADATA", `${field} must be an ISO timestamp`);235 fail("INVALID_METADATA", `${field} must be an ISO timestamp`);
@@ -500,6 +506,8 @@ function createSchema(database: DatabaseSync): void {
500 stop_label TEXT NOT NULL,506 stop_label TEXT NOT NULL,
501 line_label TEXT NOT NULL,507 line_label TEXT NOT NULL,
502 destination_label TEXT NOT NULL,508 destination_label TEXT NOT NULL,
509+ line_color TEXT NOT NULL,
510+ line_text_color TEXT NOT NULL,
503 monitoring_ref TEXT NOT NULL,511 monitoring_ref TEXT NOT NULL,
504 line_ref TEXT NOT NULL,512 line_ref TEXT NOT NULL,
505 direction_id TEXT NOT NULL,513 direction_id TEXT NOT NULL,
@@ -575,7 +583,9 @@ function createSchema(database: DatabaseSync): void {
575 shortname_line TEXT NOT NULL,583 shortname_line TEXT NOT NULL,
576 transport_mode TEXT NOT NULL,584 transport_mode TEXT NOT NULL,
577 transport_submode TEXT NOT NULL,585 transport_submode TEXT NOT NULL,
578- status TEXT NOT NULL586+ status TEXT NOT NULL,
587+ line_color TEXT NOT NULL,
588+ line_text_color TEXT NOT NULL
579 ) STRICT;589 ) STRICT;
580 CREATE TABLE stage_arrets_lignes (590 CREATE TABLE stage_arrets_lignes (
581 route_id TEXT NOT NULL,591 route_id TEXT NOT NULL,
@@ -596,7 +606,7 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
596 const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)");606 const arret = database.prepare("INSERT INTO stage_arrets VALUES (?, ?)");
597 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");607 const zone = database.prepare("INSERT INTO stage_zones VALUES (?, ?, ?)");
598 const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)");608 const relation = database.prepare("INSERT OR IGNORE INTO stage_relations VALUES (?, ?, ?)");
599- const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?)");609+ const line = database.prepare("INSERT INTO stage_lines VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
600 const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)");610 const arretLigne = database.prepare("INSERT OR IGNORE INTO stage_arrets_lignes VALUES (?, ?)");
601 611
602 return {612 return {
@@ -649,8 +659,8 @@ function prepareSpecs(database: DatabaseSync): Readonly<Record<keyof CatalogReco
649 insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")),659 insert: (row) => relation.run(nonEmpty(row.zdaid, "relations.zdaid"), nonEmpty(row.arrid, "relations.arrid"), nonEmpty(row.artid, "relations.artid")),
650 },660 },
651 lines: {661 lines: {
652- required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status"],662+ required: ["id_line", "name_line", "shortname_line", "transportmode", "transportsubmode", "status", "colourweb_hexa", "textcolourweb_hexa"],
653- insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status")),663+ insert: (row) => line.run(canonicalLineId(row.id_line, "lines.id_line"), nonEmpty(row.name_line, "lines.name_line"), optionalText(row.shortname_line), nonEmpty(row.transportmode, "lines.transportmode"), optionalText(row.transportsubmode), nonEmpty(row.status, "lines.status"), normalizeLineColor(row.colourweb_hexa, "lines.colourweb_hexa"), normalizeLineColor(row.textcolourweb_hexa, "lines.textcolourweb_hexa")),
654 },664 },
655 arretsLignes: {665 arretsLignes: {
656 required: ["id", "stop_id"],666 required: ["id", "stop_id"],
@@ -959,7 +969,9 @@ function deriveCandidates(database: DatabaseSync): void {
959 CREATE TABLE stage_line_modes (969 CREATE TABLE stage_line_modes (
960 id_line TEXT PRIMARY KEY,970 id_line TEXT PRIMARY KEY,
961 mode TEXT NOT NULL,971 mode TEXT NOT NULL,
962- line_label TEXT NOT NULL972+ line_label TEXT NOT NULL,
973+ line_color TEXT NOT NULL,
974+ line_text_color TEXT NOT NULL
963 ) STRICT;975 ) STRICT;
964 976
965 INSERT INTO stage_line_modes977 INSERT INTO stage_line_modes
@@ -971,7 +983,9 @@ function deriveCandidates(database: DatabaseSync): void {
971 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER'983 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'local' THEN 'RER'
972 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN'984 WHEN lower(trim(transport_mode)) = 'rail' AND lower(trim(transport_submode)) = 'suburbanrailway' THEN 'TRANSILIEN'
973 END,985 END,
974- coalesce(nullif(trim(shortname_line), ''), trim(name_line))986+ coalesce(nullif(trim(shortname_line), ''), trim(name_line)),
987+ line_color,
988+ line_text_color
975 FROM stage_lines989 FROM stage_lines
976 WHERE lower(trim(status)) = 'active'990 WHERE lower(trim(status)) = 'active'
977 AND (991 AND (
@@ -1040,6 +1054,8 @@ function deriveCandidates(database: DatabaseSync): void {
1040 zone.zdatown AS locality_label,1054 zone.zdatown AS locality_label,
1041 line_mode.id_line AS id_line,1055 line_mode.id_line AS id_line,
1042 line_mode.line_label AS line_label,1056 line_mode.line_label AS line_label,
1057+ line_mode.line_color AS line_color,
1058+ line_mode.line_text_color AS line_text_color,
1043 CASE1059 CASE
1044 WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name1060 WHEN line_mode.mode IN ('RER', 'TRANSILIEN') THEN terminal.stop_name
1045 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name)1061 ELSE coalesce(nullif(trim(trip.trip_headsign), ''), terminal.stop_name)
@@ -1089,7 +1105,8 @@ function deriveCandidates(database: DatabaseSync): void {
1089 AND trim(destination_code.object_code) <> ''1105 AND trim(destination_code.object_code) <> ''
1090 )1106 )
1091 SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label,1107 SELECT mode, zdaid, min(stop_label) AS stop_label, min(locality_label) AS locality_label,
1092- id_line, min(line_label) AS line_label, min(destination_label) AS destination_label,1108+ id_line, min(line_label) AS line_label, min(line_color) AS line_color,
1109+ min(line_text_color) AS line_text_color, min(destination_label) AS destination_label,
1093 monitoring_ref, line_ref, direction_id, destination_ref,1110 monitoring_ref, line_ref, direction_id, destination_ref,
1094 max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets1111 max(CASE WHEN resolution = 'arrets' THEN 1 ELSE 0 END) AS via_arrets
1095 FROM joined1112 FROM joined
@@ -1107,7 +1124,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
1107 1124
1108 const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");1125 const insertPlace = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
1109 const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");1126 const insertSearch = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
1110- const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");1127+ const insertService = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
1111 const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?");1128 const selectPlace = database.prepare("SELECT canonical_tuple FROM places WHERE place_id = ?");
1112 const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?");1129 const selectService = database.prepare("SELECT place_id, canonical_tuple FROM services WHERE service_id = ?");
1113 1130
@@ -1146,7 +1163,7 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
1146 1163
1147 const candidates = database.prepare(`1164 const candidates = database.prepare(`
1148 SELECT mode, zdaid, stop_label, line_label, destination_label,1165 SELECT mode, zdaid, stop_label, line_label, destination_label,
1149- id_line, monitoring_ref, line_ref, direction_id, destination_ref1166+ line_color, line_text_color, id_line, monitoring_ref, line_ref, direction_id, destination_ref
1150 FROM stage_candidates1167 FROM stage_candidates
1151 ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref1168 ORDER BY mode, zdaid, id_line, monitoring_ref, direction_id, destination_ref
1152 `).iterate() as Iterable<QueryRow>;1169 `).iterate() as Iterable<QueryRow>;
@@ -1174,6 +1191,8 @@ function insertFinalCatalog(database: DatabaseSync, sourceRevision: string, crea
1174 nonEmpty(row.stop_label, "candidate.stop_label"),1191 nonEmpty(row.stop_label, "candidate.stop_label"),
1175 nonEmpty(row.line_label, "candidate.line_label"),1192 nonEmpty(row.line_label, "candidate.line_label"),
1176 nonEmpty(row.destination_label, "candidate.destination_label"),1193 nonEmpty(row.destination_label, "candidate.destination_label"),
1194+ normalizeLineColor(row.line_color, "candidate.line_color"),
1195+ normalizeLineColor(row.line_text_color, "candidate.line_text_color"),
1177 nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"),1196 nonEmpty(row.monitoring_ref, "candidate.monitoring_ref"),
1178 nonEmpty(row.line_ref, "candidate.line_ref"),1197 nonEmpty(row.line_ref, "candidate.line_ref"),
1179 nonEmpty(row.direction_id, "candidate.direction_id"),1198 nonEmpty(row.direction_id, "candidate.direction_id"),
@@ -1206,6 +1225,8 @@ function validateByteBounds(database: DatabaseSync): void {
1206 OR length(CAST(stop_label AS BLOB)) > ?1225 OR length(CAST(stop_label AS BLOB)) > ?
1207 OR length(CAST(line_label AS BLOB)) > ?1226 OR length(CAST(line_label AS BLOB)) > ?
1208 OR length(CAST(destination_label AS BLOB)) > ?1227 OR length(CAST(destination_label AS BLOB)) > ?
1228+ OR length(CAST(line_color AS BLOB)) <> 7
1229+ OR length(CAST(line_text_color AS BLOB)) <> 7
1209 LIMIT 11230 LIMIT 1
1210 `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes);1231 `).get(LIMITS.idUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes, LIMITS.labelUtf8Bytes);
1211 if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit");1232 if (invalidService !== undefined) fail("CATALOG_LIMIT", "service exceeds a shared UTF-8 byte limit");
@@ -1228,7 +1249,7 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
1228 database.exec("PRAGMA foreign_keys = ON");1249 database.exec("PRAGMA foreign_keys = ON");
1229 exactColumns(database, "metadata", ["key", "value"]);1250 exactColumns(database, "metadata", ["key", "value"]);
1230 exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]);1251 exactColumns(database, "places", ["place_id", "stop_label", "locality_label", "mode", "canonical_tuple"]);
1231- exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]);1252+ exactColumns(database, "services", ["service_id", "place_id", "stop_label", "line_label", "destination_label", "line_color", "line_text_color", "monitoring_ref", "line_ref", "direction_id", "destination_ref", "canonical_tuple"]);
1232 exactColumns(database, "place_search", ["search_text", "place_id"]);1253 exactColumns(database, "place_search", ["search_text", "place_id"]);
1233 1254
1234 const sourceRevision = metadataValue(database, "source_revision");1255 const sourceRevision = metadataValue(database, "source_revision");
@@ -1276,9 +1297,11 @@ function validateOpenCatalog(database: DatabaseSync): CatalogBuildResult {
1276 const unresolved = database.prepare(`1297 const unresolved = database.prepare(`
1277 SELECT 1 FROM services1298 SELECT 1 FROM services
1278 WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = ''1299 WHERE trim(monitoring_ref) = '' OR trim(line_ref) = '' OR trim(direction_id) = '' OR trim(destination_ref) = ''
1300+ OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
1301+ OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
1279 LIMIT 11302 LIMIT 1
1280 `).get();1303 `).get();
1281- if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable offered service");1304+ if (unresolved !== undefined) fail("CATALOG_UNRESOLVED", "catalog contains an unresolvable or malformed offered service");
1282 1305
1283 for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) {1306 for (const row of database.prepare("SELECT place_id, canonical_tuple FROM places").iterate() as Iterable<QueryRow>) {
1284 if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple");1307 if (opaqueId("plc_", String(row.canonical_tuple)) !== row.place_id) fail("HASH_COLLISION", "place ID does not match its canonical tuple");
modified packages/backend/src/catalog.ts +22 -6
@@ -48,6 +48,9 @@ interface ServiceRow {
4848 readonly stopLabel: string;
4949 readonly lineLabel: string;
5050 readonly destinationLabel: string;
51+ readonly lineMode: TransportMode;
52+ readonly lineColor: string;
53+ readonly lineTextColor: string;
5154 }
5255
5356 interface ResolutionRow {
@@ -87,7 +90,7 @@ interface ExpectedColumn {
8790 const CLOSE_READER = Symbol("closeCatalogReader");
8891 const OPAQUE_ID_BYTES = 47;
8992 const CATALOG_SCHEMA_VERSION = "1";
90-const CATALOG_FORMAT_VERSION = "1";
93+const CATALOG_FORMAT_VERSION = "2";
9194 const REQUIRED_METADATA = [
9295 "schema_version",
9396 "catalog_version",
@@ -123,6 +126,8 @@ const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = {
123126 { name: "stop_label", type: "TEXT", notNull: true },
124127 { name: "line_label", type: "TEXT", notNull: true },
125128 { name: "destination_label", type: "TEXT", notNull: true },
129+ { name: "line_color", type: "TEXT", notNull: true },
130+ { name: "line_text_color", type: "TEXT", notNull: true },
126131 { name: "monitoring_ref", type: "TEXT", notNull: true },
127132 { name: "line_ref", type: "TEXT", notNull: true },
128133 { name: "direction_id", type: "TEXT", notNull: true },
@@ -322,6 +327,10 @@ function validateData(database: DatabaseSync): void {
322327 OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
323328 OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
324329 OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
330+ OR length(CAST(line_color AS BLOB)) <> 7
331+ OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
332+ OR length(CAST(line_text_color AS BLOB)) <> 7
333+ OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
325334 OR length(CAST(monitoring_ref AS BLOB)) < 1
326335 OR length(CAST(line_ref AS BLOB)) < 1
327336 OR length(CAST(direction_id AS BLOB)) < 1
@@ -482,12 +491,16 @@ export class SqliteCatalogReader implements CatalogReader {
482491 `);
483492 this.#listServicesStatement = database.prepare(`
484493 SELECT
485- service_id AS serviceId,
486- stop_label AS stopLabel,
487- line_label AS lineLabel,
488- destination_label AS destinationLabel
494+ services.service_id AS serviceId,
495+ services.stop_label AS stopLabel,
496+ services.line_label AS lineLabel,
497+ services.destination_label AS destinationLabel,
498+ places.mode AS lineMode,
499+ services.line_color AS lineColor,
500+ services.line_text_color AS lineTextColor
489501 FROM services
490- WHERE place_id = ?
502+ JOIN places ON places.place_id = services.place_id
503+ WHERE services.place_id = ?
491504 ORDER BY
492505 line_label COLLATE NOCASE,
493506 destination_label COLLATE NOCASE,
@@ -544,6 +557,9 @@ export class SqliteCatalogReader implements CatalogReader {
544557 stopLabel: entry.stopLabel,
545558 lineLabel: entry.lineLabel,
546559 destinationLabel: entry.destinationLabel,
560+ lineMode: entry.lineMode,
561+ lineColor: entry.lineColor,
562+ lineTextColor: entry.lineTextColor,
547563 }));
548564 }
549565
@@ -48,6 +48,9 @@ interface ServiceRow {
48 readonly stopLabel: string;48 readonly stopLabel: string;
49 readonly lineLabel: string;49 readonly lineLabel: string;
50 readonly destinationLabel: string;50 readonly destinationLabel: string;
51+ readonly lineMode: TransportMode;
52+ readonly lineColor: string;
53+ readonly lineTextColor: string;
51 }54 }
52 55
53 interface ResolutionRow {56 interface ResolutionRow {
@@ -87,7 +90,7 @@ interface ExpectedColumn {
87 const CLOSE_READER = Symbol("closeCatalogReader");90 const CLOSE_READER = Symbol("closeCatalogReader");
88 const OPAQUE_ID_BYTES = 47;91 const OPAQUE_ID_BYTES = 47;
89 const CATALOG_SCHEMA_VERSION = "1";92 const CATALOG_SCHEMA_VERSION = "1";
90-const CATALOG_FORMAT_VERSION = "1";93+const CATALOG_FORMAT_VERSION = "2";
91 const REQUIRED_METADATA = [94 const REQUIRED_METADATA = [
92 "schema_version",95 "schema_version",
93 "catalog_version",96 "catalog_version",
@@ -123,6 +126,8 @@ const TABLE_COLUMNS: Readonly<Record<string, readonly ExpectedColumn[]>> = {
123 { name: "stop_label", type: "TEXT", notNull: true },126 { name: "stop_label", type: "TEXT", notNull: true },
124 { name: "line_label", type: "TEXT", notNull: true },127 { name: "line_label", type: "TEXT", notNull: true },
125 { name: "destination_label", type: "TEXT", notNull: true },128 { name: "destination_label", type: "TEXT", notNull: true },
129+ { name: "line_color", type: "TEXT", notNull: true },
130+ { name: "line_text_color", type: "TEXT", notNull: true },
126 { name: "monitoring_ref", type: "TEXT", notNull: true },131 { name: "monitoring_ref", type: "TEXT", notNull: true },
127 { name: "line_ref", type: "TEXT", notNull: true },132 { name: "line_ref", type: "TEXT", notNull: true },
128 { name: "direction_id", type: "TEXT", notNull: true },133 { name: "direction_id", type: "TEXT", notNull: true },
@@ -322,6 +327,10 @@ function validateData(database: DatabaseSync): void {
322 OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}327 OR length(CAST(stop_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
323 OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}328 OR length(CAST(line_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
324 OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}329 OR length(CAST(destination_label AS BLOB)) NOT BETWEEN 1 AND ${LIMITS.labelUtf8Bytes}
330+ OR length(CAST(line_color AS BLOB)) <> 7
331+ OR line_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
332+ OR length(CAST(line_text_color AS BLOB)) <> 7
333+ OR line_text_color NOT GLOB '#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]'
325 OR length(CAST(monitoring_ref AS BLOB)) < 1334 OR length(CAST(monitoring_ref AS BLOB)) < 1
326 OR length(CAST(line_ref AS BLOB)) < 1335 OR length(CAST(line_ref AS BLOB)) < 1
327 OR length(CAST(direction_id AS BLOB)) < 1336 OR length(CAST(direction_id AS BLOB)) < 1
@@ -482,12 +491,16 @@ export class SqliteCatalogReader implements CatalogReader {
482 `);491 `);
483 this.#listServicesStatement = database.prepare(`492 this.#listServicesStatement = database.prepare(`
484 SELECT493 SELECT
485- service_id AS serviceId,494+ services.service_id AS serviceId,
486- stop_label AS stopLabel,495+ services.stop_label AS stopLabel,
487- line_label AS lineLabel,496+ services.line_label AS lineLabel,
488- destination_label AS destinationLabel497+ services.destination_label AS destinationLabel,
498+ places.mode AS lineMode,
499+ services.line_color AS lineColor,
500+ services.line_text_color AS lineTextColor
489 FROM services501 FROM services
490- WHERE place_id = ?502+ JOIN places ON places.place_id = services.place_id
503+ WHERE services.place_id = ?
491 ORDER BY504 ORDER BY
492 line_label COLLATE NOCASE,505 line_label COLLATE NOCASE,
493 destination_label COLLATE NOCASE,506 destination_label COLLATE NOCASE,
@@ -544,6 +557,9 @@ export class SqliteCatalogReader implements CatalogReader {
544 stopLabel: entry.stopLabel,557 stopLabel: entry.stopLabel,
545 lineLabel: entry.lineLabel,558 lineLabel: entry.lineLabel,
546 destinationLabel: entry.destinationLabel,559 destinationLabel: entry.destinationLabel,
560+ lineMode: entry.lineMode,
561+ lineColor: entry.lineColor,
562+ lineTextColor: entry.lineTextColor,
547 }));563 }));
548 }564 }
549 565
modified packages/backend/test/catalog-http.test.ts +5 -5
@@ -55,20 +55,20 @@ function fixtureManager(): CatalogManager {
5555 PRAGMA foreign_keys = ON;
5656 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
5757 CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
58- CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
58+ CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, line_color TEXT NOT NULL, line_text_color TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
5959 CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED);
6060 `);
6161 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
62- metadata.run("schema_version", "1"); metadata.run("catalog_version", "1");
62+ metadata.run("schema_version", "1"); metadata.run("catalog_version", "2");
6363 metadata.run("source_revision", "http-fixture-revision");
6464 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
6565 const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
6666 const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
67- const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
67+ const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
6868 FIXTURE.forEach((entry, index) => {
6969 place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`);
7070 search.run(normalizedSearchText(entry), entry.id);
71- service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);
71+ service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, "#123456", "#ffffff", entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);
7272 });
7373 database.close();
7474 const manager = new CatalogManager();
@@ -100,7 +100,7 @@ test("GET routes return exact public response bodies without source identifiers
100100 assert.equal(services.status, 200);
101101 assert.ok(isServiceOptionsResult(services.body));
102102 assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [
103- { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" },
103+ { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac", lineMode: "METRO", lineColor: "#123456", lineTextColor: "#ffffff" },
104104 ] });
105105 const publicJson = JSON.stringify([places.body, services.body]);
106106 for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) {
@@ -55,20 +55,20 @@ function fixtureManager(): CatalogManager {
55 PRAGMA foreign_keys = ON;55 PRAGMA foreign_keys = ON;
56 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);56 CREATE TABLE metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
57 CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);57 CREATE TABLE places(place_id TEXT PRIMARY KEY, stop_label TEXT NOT NULL, locality_label TEXT, mode TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
58- CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);58+ CREATE TABLE services(service_id TEXT PRIMARY KEY, place_id TEXT NOT NULL REFERENCES places(place_id), stop_label TEXT NOT NULL, line_label TEXT NOT NULL, destination_label TEXT NOT NULL, line_color TEXT NOT NULL, line_text_color TEXT NOT NULL, monitoring_ref TEXT NOT NULL, line_ref TEXT NOT NULL, direction_id TEXT NOT NULL, destination_ref TEXT NOT NULL, canonical_tuple TEXT NOT NULL UNIQUE);
59 CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED);59 CREATE VIRTUAL TABLE place_search USING fts5(search_text, place_id UNINDEXED);
60 `);60 `);
61 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");61 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
62- metadata.run("schema_version", "1"); metadata.run("catalog_version", "1");62+ metadata.run("schema_version", "1"); metadata.run("catalog_version", "2");
63 metadata.run("source_revision", "http-fixture-revision");63 metadata.run("source_revision", "http-fixture-revision");
64 metadata.run("created_at", "2026-08-30T12:00:00.000Z");64 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
65 const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");65 const place = database.prepare("INSERT INTO places VALUES (?, ?, ?, ?, ?)");
66 const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");66 const search = database.prepare("INSERT INTO place_search(search_text, place_id) VALUES (?, ?)");
67- const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");67+ const service = database.prepare("INSERT INTO services VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
68 FIXTURE.forEach((entry, index) => {68 FIXTURE.forEach((entry, index) => {
69 place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`);69 place.run(entry.id, entry.label, entry.locality, entry.mode, `place:v1|${index}:${entry.id}`);
70 search.run(normalizedSearchText(entry), entry.id);70 search.run(normalizedSearchText(entry), entry.id);
71- service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);71+ service.run(entry.serviceId, entry.id, entry.label, entry.line, entry.destination, "#123456", "#ffffff", entry.monitoringRef, entry.lineRef, `direction-${index}`, `raw-terminal-${index}`, `service:v1|${index}:${entry.serviceId}`);
72 });72 });
73 database.close();73 database.close();
74 const manager = new CatalogManager();74 const manager = new CatalogManager();
@@ -100,7 +100,7 @@ test("GET routes return exact public response bodies without source identifiers
100 assert.equal(services.status, 200);100 assert.equal(services.status, 200);
101 assert.ok(isServiceOptionsResult(services.body));101 assert.ok(isServiceOptionsResult(services.body));
102 assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [102 assert.deepEqual(services.body, { schemaVersion: SCHEMA_VERSION, placeId: PLACE_ID, services: [
103- { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac" },103+ { serviceId: SERVICE_ID, stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux – Lucie Aubrac", lineMode: "METRO", lineColor: "#123456", lineTextColor: "#ffffff" },
104 ] });104 ] });
105 const publicJson = JSON.stringify([places.body, services.body]);105 const publicJson = JSON.stringify([places.body, services.body]);
106 for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) {106 for (const forbidden of [PERSONAL_KEY, "raw-monitoring-metro-chatelet", "raw-line-metro-4", "raw-terminal-0", "direction-0"]) {
modified packages/backend/test/catalog-import.test.ts +47 -0
@@ -107,6 +107,13 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
107107 const metroServices = manager.listServices(metro.placeId) ?? [];
108108 assert.equal(metroServices.length, 2);
109109 assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId);
110+ assert.deepEqual(
111+ metroServices.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
112+ [
113+ { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
114+ { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
115+ ],
116+ );
110117 const rer = place(manager, "chatelet les halles", "RER");
111118 const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy");
112119 const west = service(manager, rer.placeId, "Saint-Germain-en-Laye");
@@ -129,6 +136,46 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
129136 assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u);
130137 });
131138
139+test("import normalizes valid colors and rejects invalid or missing authoritative colors", async (t) => {
140+ const directory = workspace(t);
141+ const validSources = sourcesFor("fixture-2026-08-a");
142+ const invalidSources: CatalogRecordSourceSet = {
143+ ...validSources,
144+ lines: validSources.lines.map((line, index) => index === 0
145+ ? { ...line, colourweb_hexa: "not-a-color" }
146+ : line),
147+ };
148+ const invalidCandidate = join(directory, "invalid-color.sqlite");
149+ await assert.rejects(
150+ buildCatalogCandidateFromRecords({
151+ candidatePath: invalidCandidate,
152+ sourceRevision: "fixture-invalid-color",
153+ sources: invalidSources,
154+ }),
155+ /six-digit hexadecimal color/u,
156+ );
157+ assert.equal(existsSync(invalidCandidate), false);
158+
159+ const missingSources: CatalogRecordSourceSet = {
160+ ...validSources,
161+ lines: validSources.lines.map((line, index) => {
162+ if (index !== 0) return line;
163+ const { textcolourweb_hexa: _missing, ...withoutTextColor } = line;
164+ return withoutTextColor;
165+ }),
166+ };
167+ const missingCandidate = join(directory, "missing-color.sqlite");
168+ await assert.rejects(
169+ buildCatalogCandidateFromRecords({
170+ candidatePath: missingCandidate,
171+ sourceRevision: "fixture-missing-color",
172+ sources: missingSources,
173+ }),
174+ /missing textcolourweb_hexa/u,
175+ );
176+ assert.equal(existsSync(missingCandidate), false);
177+});
178+
132179 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
133180 const directory = workspace(t);
134181 const active = join(directory, "catalog.sqlite");
@@ -107,6 +107,13 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
107 const metroServices = manager.listServices(metro.placeId) ?? [];107 const metroServices = manager.listServices(metro.placeId) ?? [];
108 assert.equal(metroServices.length, 2);108 assert.equal(metroServices.length, 2);
109 assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId);109 assert.notEqual(metroServices[0]?.serviceId, metroServices[1]?.serviceId);
110+ assert.deepEqual(
111+ metroServices.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
112+ [
113+ { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
114+ { lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
115+ ],
116+ );
110 const rer = place(manager, "chatelet les halles", "RER");117 const rer = place(manager, "chatelet les halles", "RER");
111 const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy");118 const east = service(manager, rer.placeId, "Marne-la-Vallée Chessy");
112 const west = service(manager, rer.placeId, "Saint-Germain-en-Laye");119 const west = service(manager, rer.placeId, "Saint-Germain-en-Laye");
@@ -129,6 +136,46 @@ test("import covers five modes, gates perimeter and unsupported modes, preserves
129 assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u);136 assert.doesNotMatch(JSON.stringify([...manager.searchPlaces("chatelet"), ...metroServices, ...busServices]), /STIF:|IDFM:|DEST:/u);
130 });137 });
131 138
139+test("import normalizes valid colors and rejects invalid or missing authoritative colors", async (t) => {
140+ const directory = workspace(t);
141+ const validSources = sourcesFor("fixture-2026-08-a");
142+ const invalidSources: CatalogRecordSourceSet = {
143+ ...validSources,
144+ lines: validSources.lines.map((line, index) => index === 0
145+ ? { ...line, colourweb_hexa: "not-a-color" }
146+ : line),
147+ };
148+ const invalidCandidate = join(directory, "invalid-color.sqlite");
149+ await assert.rejects(
150+ buildCatalogCandidateFromRecords({
151+ candidatePath: invalidCandidate,
152+ sourceRevision: "fixture-invalid-color",
153+ sources: invalidSources,
154+ }),
155+ /six-digit hexadecimal color/u,
156+ );
157+ assert.equal(existsSync(invalidCandidate), false);
158+
159+ const missingSources: CatalogRecordSourceSet = {
160+ ...validSources,
161+ lines: validSources.lines.map((line, index) => {
162+ if (index !== 0) return line;
163+ const { textcolourweb_hexa: _missing, ...withoutTextColor } = line;
164+ return withoutTextColor;
165+ }),
166+ };
167+ const missingCandidate = join(directory, "missing-color.sqlite");
168+ await assert.rejects(
169+ buildCatalogCandidateFromRecords({
170+ candidatePath: missingCandidate,
171+ sourceRevision: "fixture-missing-color",
172+ sources: missingSources,
173+ }),
174+ /missing textcolourweb_hexa/u,
175+ );
176+ assert.equal(existsSync(missingCandidate), false);
177+});
178+
132 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {179 test("activation preserves stable services, removes stale IDs, and failed replacement leaves the active file untouched", async (t) => {
133 const directory = workspace(t);180 const directory = workspace(t);
134 const active = join(directory, "catalog.sqlite");181 const active = join(directory, "catalog.sqlite");
modified packages/backend/test/catalog.test.ts +31 -3
@@ -23,6 +23,8 @@ interface ServiceSeed {
2323 readonly stopLabel: string;
2424 readonly lineLabel: string;
2525 readonly destinationLabel: string;
26+ readonly lineColor?: string;
27+ readonly lineTextColor?: string;
2628 readonly monitoringRef: string;
2729 readonly lineRef: string;
2830 readonly directionId: string;
@@ -208,6 +210,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
208210 stop_label TEXT NOT NULL,
209211 line_label TEXT NOT NULL,
210212 destination_label TEXT NOT NULL,
213+ line_color TEXT NOT NULL,
214+ line_text_color TEXT NOT NULL,
211215 monitoring_ref TEXT NOT NULL,
212216 line_ref TEXT NOT NULL,
213217 direction_id TEXT NOT NULL,
@@ -219,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
219223
220224 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
221225 metadata.run("schema_version", "1");
222- metadata.run("catalog_version", seed.catalogVersion ?? "1");
226+ metadata.run("catalog_version", seed.catalogVersion ?? "2");
223227 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");
224228 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
225229
@@ -258,12 +262,14 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
258262 stop_label,
259263 line_label,
260264 destination_label,
265+ line_color,
266+ line_text_color,
261267 monitoring_ref,
262268 line_ref,
263269 direction_id,
264270 destination_ref,
265271 canonical_tuple
266- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
272+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
267273 `);
268274 for (const service of services) {
269275 const identity = serviceIdentity(service);
@@ -274,6 +280,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
274280 service.stopLabel,
275281 service.lineLabel,
276282 service.destinationLabel,
283+ service.lineColor ?? "#123456",
284+ service.lineTextColor ?? "#ffffff",
277285 service.monitoringRef,
278286 service.lineRef,
279287 service.directionId,
@@ -424,7 +432,7 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
424432 destinationLabel: "Saint-Rémy-lès-Chevreuse",
425433 });
426434
427- const invalid = buildCatalog({ catalogVersion: "2" });
435+ const invalid = buildCatalog({ catalogVersion: "1" });
428436 assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u);
429437 assert.equal(manager.resolveService(southId).status, "RESOLVED");
430438
@@ -455,13 +463,33 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
455463 assert.equal(manager.listServices("plc_missing"), undefined);
456464 assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [
457465 "destinationLabel",
466+ "lineColor",
458467 "lineLabel",
468+ "lineMode",
469+ "lineTextColor",
459470 "serviceId",
460471 "stopLabel",
461472 ]);
473+ assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], {
474+ serviceId: replacement.serviceIds["metro-south"],
475+ stopLabel: "Châtelet",
476+ lineLabel: "4",
477+ destinationLabel: "Bagneux – Lucie Aubrac",
478+ lineMode: "METRO",
479+ lineColor: "#123456",
480+ lineTextColor: "#ffffff",
481+ });
462482 });
463483
464484 test("candidate validation rejects incomplete FTS coverage", () => {
465485 const invalid = buildCatalog({ omitSearchFor: "metro" });
466486 assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u);
467487 });
488+
489+test("candidate validation rejects non-normalized presentation colors", () => {
490+ const invalid = buildCatalog({
491+ omitServiceKeys: ["metro-south"],
492+ services: [{ ...BASE_SERVICES.find((service) => service.key === "metro-south")!, lineColor: "#ABCDEF" }],
493+ });
494+ assert.throws(() => SqliteCatalogReader.open(invalid.path), /service data/u);
495+});
@@ -23,6 +23,8 @@ interface ServiceSeed {
23 readonly stopLabel: string;23 readonly stopLabel: string;
24 readonly lineLabel: string;24 readonly lineLabel: string;
25 readonly destinationLabel: string;25 readonly destinationLabel: string;
26+ readonly lineColor?: string;
27+ readonly lineTextColor?: string;
26 readonly monitoringRef: string;28 readonly monitoringRef: string;
27 readonly lineRef: string;29 readonly lineRef: string;
28 readonly directionId: string;30 readonly directionId: string;
@@ -208,6 +210,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
208 stop_label TEXT NOT NULL,210 stop_label TEXT NOT NULL,
209 line_label TEXT NOT NULL,211 line_label TEXT NOT NULL,
210 destination_label TEXT NOT NULL,212 destination_label TEXT NOT NULL,
213+ line_color TEXT NOT NULL,
214+ line_text_color TEXT NOT NULL,
211 monitoring_ref TEXT NOT NULL,215 monitoring_ref TEXT NOT NULL,
212 line_ref TEXT NOT NULL,216 line_ref TEXT NOT NULL,
213 direction_id TEXT NOT NULL,217 direction_id TEXT NOT NULL,
@@ -219,7 +223,7 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
219 223
220 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");224 const metadata = database.prepare("INSERT INTO metadata(key, value) VALUES (?, ?)");
221 metadata.run("schema_version", "1");225 metadata.run("schema_version", "1");
222- metadata.run("catalog_version", seed.catalogVersion ?? "1");226+ metadata.run("catalog_version", seed.catalogVersion ?? "2");
223 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");227 metadata.run("source_revision", seed.revision ?? "fixture-revision-a");
224 metadata.run("created_at", "2026-08-30T12:00:00.000Z");228 metadata.run("created_at", "2026-08-30T12:00:00.000Z");
225 229
@@ -258,12 +262,14 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
258 stop_label,262 stop_label,
259 line_label,263 line_label,
260 destination_label,264 destination_label,
265+ line_color,
266+ line_text_color,
261 monitoring_ref,267 monitoring_ref,
262 line_ref,268 line_ref,
263 direction_id,269 direction_id,
264 destination_ref,270 destination_ref,
265 canonical_tuple271 canonical_tuple
266- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)272+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
267 `);273 `);
268 for (const service of services) {274 for (const service of services) {
269 const identity = serviceIdentity(service);275 const identity = serviceIdentity(service);
@@ -274,6 +280,8 @@ function buildCatalog(seed: CatalogSeed = {}): BuiltCatalog {
274 service.stopLabel,280 service.stopLabel,
275 service.lineLabel,281 service.lineLabel,
276 service.destinationLabel,282 service.destinationLabel,
283+ service.lineColor ?? "#123456",
284+ service.lineTextColor ?? "#ffffff",
277 service.monitoringRef,285 service.monitoringRef,
278 service.lineRef,286 service.lineRef,
279 service.directionId,287 service.directionId,
@@ -424,7 +432,7 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
424 destinationLabel: "Saint-Rémy-lès-Chevreuse",432 destinationLabel: "Saint-Rémy-lès-Chevreuse",
425 });433 });
426 434
427- const invalid = buildCatalog({ catalogVersion: "2" });435+ const invalid = buildCatalog({ catalogVersion: "1" });
428 assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u);436 assert.throws(() => manager.reload(invalid.path), /Invalid catalog candidate/u);
429 assert.equal(manager.resolveService(southId).status, "RESOLVED");437 assert.equal(manager.resolveService(southId).status, "RESOLVED");
430 438
@@ -455,13 +463,33 @@ test("reload preserves stable IDs, distinguishes directions, rejects invalid can
455 assert.equal(manager.listServices("plc_missing"), undefined);463 assert.equal(manager.listServices("plc_missing"), undefined);
456 assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [464 assert.deepEqual(Object.keys(manager.listServices(replacement.placeIds.metro)![0]!).sort(), [
457 "destinationLabel",465 "destinationLabel",
466+ "lineColor",
458 "lineLabel",467 "lineLabel",
468+ "lineMode",
469+ "lineTextColor",
459 "serviceId",470 "serviceId",
460 "stopLabel",471 "stopLabel",
461 ]);472 ]);
473+ assert.deepEqual(manager.listServices(replacement.placeIds.metro)![0], {
474+ serviceId: replacement.serviceIds["metro-south"],
475+ stopLabel: "Châtelet",
476+ lineLabel: "4",
477+ destinationLabel: "Bagneux – Lucie Aubrac",
478+ lineMode: "METRO",
479+ lineColor: "#123456",
480+ lineTextColor: "#ffffff",
481+ });
462 });482 });
463 483
464 test("candidate validation rejects incomplete FTS coverage", () => {484 test("candidate validation rejects incomplete FTS coverage", () => {
465 const invalid = buildCatalog({ omitSearchFor: "metro" });485 const invalid = buildCatalog({ omitSearchFor: "metro" });
466 assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u);486 assert.throws(() => SqliteCatalogReader.open(invalid.path), /search consistency/u);
467 });487 });
488+
489+test("candidate validation rejects non-normalized presentation colors", () => {
490+ const invalid = buildCatalog({
491+ omitServiceKeys: ["metro-south"],
492+ services: [{ ...BASE_SERVICES.find((service) => service.key === "metro-south")!, lineColor: "#ABCDEF" }],
493+ });
494+ assert.throws(() => SqliteCatalogReader.open(invalid.path), /service data/u);
495+});
modified packages/companion/src/configuration.js +4 -1
@@ -11,7 +11,10 @@ var FAVORITE_STRING_KEYS = [
1111 "displayName",
1212 "stopLabel",
1313 "lineLabel",
14- "destinationLabel"
14+ "destinationLabel",
15+ "lineMode",
16+ "lineColor",
17+ "lineTextColor"
1518 ];
1619
1720 function emptyConfiguration() {
@@ -11,7 +11,10 @@ var FAVORITE_STRING_KEYS = [
11 "displayName",11 "displayName",
12 "stopLabel",12 "stopLabel",
13 "lineLabel",13 "lineLabel",
14- "destinationLabel"14+ "destinationLabel",
15+ "lineMode",
16+ "lineColor",
17+ "lineTextColor"
15 ];18 ];
16 19
17 function emptyConfiguration() {20 function emptyConfiguration() {
modified packages/companion/src/contracts.js +31 -5
@@ -25,6 +25,7 @@ var ERROR_CODE = Object.freeze([
2525 "RATE_LIMITED",
2626 "INVALID_RESPONSE"
2727 ]);
28+var TRANSPORT_MODE = Object.freeze(["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]);
2829 var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]);
2930 var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 });
3031 var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 });
@@ -103,6 +104,9 @@ var FAVORITE_KEYS = [
103104 "stopLabel",
104105 "lineLabel",
105106 "destinationLabel",
107+ "lineMode",
108+ "lineColor",
109+ "lineTextColor",
106110 "sortOrder"
107111 ];
108112 var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"];
@@ -169,6 +173,10 @@ function boundedString(value, maximum) {
169173 return size >= 1 && size <= maximum;
170174 }
171175
176+function isLineColor(value) {
177+ return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value);
178+}
179+
172180 function uint32(value) {
173181 return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX;
174182 }
@@ -178,15 +186,26 @@ function optionalUint32(value) {
178186 }
179187
180188 function isFavorite(value) {
181- return isObject(value)
182- && hasOnlyKeys(value, FAVORITE_KEYS)
183- && value.schemaVersion === SCHEMA_VERSION
189+ var hasLineColor;
190+ var hasLineMode;
191+ var hasLineTextColor;
192+ if (!isObject(value) || !hasOnlyKeys(value, FAVORITE_KEYS)) return false;
193+ hasLineMode = Object.prototype.hasOwnProperty.call(value, "lineMode");
194+ hasLineColor = Object.prototype.hasOwnProperty.call(value, "lineColor");
195+ hasLineTextColor = Object.prototype.hasOwnProperty.call(value, "lineTextColor");
196+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
197+ return value.schemaVersion === SCHEMA_VERSION
184198 && boundedString(value.id, LIMITS.idUtf8Bytes)
185199 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
186200 && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes))
187201 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
188202 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
189203 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
204+ && (!hasLineMode || (
205+ TRANSPORT_MODE.indexOf(value.lineMode) !== -1
206+ && isLineColor(value.lineColor)
207+ && isLineColor(value.lineTextColor)
208+ ))
190209 && typeof value.sortOrder === "number"
191210 && Math.floor(value.sortOrder) === value.sortOrder
192211 && value.sortOrder >= 0
@@ -262,9 +281,14 @@ function copyFavorite(favorite) {
262281 serviceId: favorite.serviceId,
263282 stopLabel: favorite.stopLabel,
264283 lineLabel: favorite.lineLabel,
265- destinationLabel: favorite.destinationLabel,
266- sortOrder: favorite.sortOrder
284+ destinationLabel: favorite.destinationLabel
267285 };
286+ if (Object.prototype.hasOwnProperty.call(favorite, "lineMode")) {
287+ copy.lineMode = favorite.lineMode;
288+ copy.lineColor = favorite.lineColor;
289+ copy.lineTextColor = favorite.lineTextColor;
290+ }
291+ copy.sortOrder = favorite.sortOrder;
268292 if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName;
269293 return copy;
270294 }
@@ -301,6 +325,7 @@ module.exports = {
301325 FRESHNESS: FRESHNESS,
302326 DEPARTURE_STATUS: DEPARTURE_STATUS,
303327 ERROR_CODE: ERROR_CODE,
328+ TRANSPORT_MODE: TRANSPORT_MODE,
304329 API_KEY_ACTION: API_KEY_ACTION,
305330 KEY_STATUS: KEY_STATUS,
306331 REQUEST_TRIGGER: REQUEST_TRIGGER,
@@ -313,6 +338,7 @@ module.exports = {
313338 isObject: isObject,
314339 hasOnlyKeys: hasOnlyKeys,
315340 boundedString: boundedString,
341+ isLineColor: isLineColor,
316342 isFavorite: isFavorite,
317343 isFavoriteList: isFavoriteList,
318344 isDeparture: isDeparture,
@@ -25,6 +25,7 @@ var ERROR_CODE = Object.freeze([
25 "RATE_LIMITED",25 "RATE_LIMITED",
26 "INVALID_RESPONSE"26 "INVALID_RESPONSE"
27 ]);27 ]);
28+var TRANSPORT_MODE = Object.freeze(["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"]);
28 var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]);29 var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]);
29 var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 });30 var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 });
30 var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 });31 var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 });
@@ -103,6 +104,9 @@ var FAVORITE_KEYS = [
103 "stopLabel",104 "stopLabel",
104 "lineLabel",105 "lineLabel",
105 "destinationLabel",106 "destinationLabel",
107+ "lineMode",
108+ "lineColor",
109+ "lineTextColor",
106 "sortOrder"110 "sortOrder"
107 ];111 ];
108 var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"];112 var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"];
@@ -169,6 +173,10 @@ function boundedString(value, maximum) {
169 return size >= 1 && size <= maximum;173 return size >= 1 && size <= maximum;
170 }174 }
171 175
176+function isLineColor(value) {
177+ return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value);
178+}
179+
172 function uint32(value) {180 function uint32(value) {
173 return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX;181 return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX;
174 }182 }
@@ -178,15 +186,26 @@ function optionalUint32(value) {
178 }186 }
179 187
180 function isFavorite(value) {188 function isFavorite(value) {
181- return isObject(value)189+ var hasLineColor;
182- && hasOnlyKeys(value, FAVORITE_KEYS)190+ var hasLineMode;
183- && value.schemaVersion === SCHEMA_VERSION191+ var hasLineTextColor;
192+ if (!isObject(value) || !hasOnlyKeys(value, FAVORITE_KEYS)) return false;
193+ hasLineMode = Object.prototype.hasOwnProperty.call(value, "lineMode");
194+ hasLineColor = Object.prototype.hasOwnProperty.call(value, "lineColor");
195+ hasLineTextColor = Object.prototype.hasOwnProperty.call(value, "lineTextColor");
196+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
197+ return value.schemaVersion === SCHEMA_VERSION
184 && boundedString(value.id, LIMITS.idUtf8Bytes)198 && boundedString(value.id, LIMITS.idUtf8Bytes)
185 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)199 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
186 && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes))200 && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes))
187 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)201 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
188 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)202 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
189 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)203 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
204+ && (!hasLineMode || (
205+ TRANSPORT_MODE.indexOf(value.lineMode) !== -1
206+ && isLineColor(value.lineColor)
207+ && isLineColor(value.lineTextColor)
208+ ))
190 && typeof value.sortOrder === "number"209 && typeof value.sortOrder === "number"
191 && Math.floor(value.sortOrder) === value.sortOrder210 && Math.floor(value.sortOrder) === value.sortOrder
192 && value.sortOrder >= 0211 && value.sortOrder >= 0
@@ -262,9 +281,14 @@ function copyFavorite(favorite) {
262 serviceId: favorite.serviceId,281 serviceId: favorite.serviceId,
263 stopLabel: favorite.stopLabel,282 stopLabel: favorite.stopLabel,
264 lineLabel: favorite.lineLabel,283 lineLabel: favorite.lineLabel,
265- destinationLabel: favorite.destinationLabel,284+ destinationLabel: favorite.destinationLabel
266- sortOrder: favorite.sortOrder
267 };285 };
286+ if (Object.prototype.hasOwnProperty.call(favorite, "lineMode")) {
287+ copy.lineMode = favorite.lineMode;
288+ copy.lineColor = favorite.lineColor;
289+ copy.lineTextColor = favorite.lineTextColor;
290+ }
291+ copy.sortOrder = favorite.sortOrder;
268 if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName;292 if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName;
269 return copy;293 return copy;
270 }294 }
@@ -301,6 +325,7 @@ module.exports = {
301 FRESHNESS: FRESHNESS,325 FRESHNESS: FRESHNESS,
302 DEPARTURE_STATUS: DEPARTURE_STATUS,326 DEPARTURE_STATUS: DEPARTURE_STATUS,
303 ERROR_CODE: ERROR_CODE,327 ERROR_CODE: ERROR_CODE,
328+ TRANSPORT_MODE: TRANSPORT_MODE,
304 API_KEY_ACTION: API_KEY_ACTION,329 API_KEY_ACTION: API_KEY_ACTION,
305 KEY_STATUS: KEY_STATUS,330 KEY_STATUS: KEY_STATUS,
306 REQUEST_TRIGGER: REQUEST_TRIGGER,331 REQUEST_TRIGGER: REQUEST_TRIGGER,
@@ -313,6 +338,7 @@ module.exports = {
313 isObject: isObject,338 isObject: isObject,
314 hasOnlyKeys: hasOnlyKeys,339 hasOnlyKeys: hasOnlyKeys,
315 boundedString: boundedString,340 boundedString: boundedString,
341+ isLineColor: isLineColor,
316 isFavorite: isFavorite,342 isFavorite: isFavorite,
317 isFavoriteList: isFavoriteList,343 isFavoriteList: isFavoriteList,
318 isDeparture: isDeparture,344 isDeparture: isDeparture,
modified packages/companion/test/companion.test.js +28 -0
@@ -126,6 +126,34 @@ test("close fragment atomically stores one favorites-and-key record and exposes
126126 target.xhr.instances[0].respond(200, responseFor("secret-boundary"));
127127 assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
128128 });
129+test("a pre-presentation v1 record keeps its API key and legacy favorite on load", function () {
130+ var storage = new fakes.FakeStorage();
131+ var legacyRecord = {
132+ schemaVersion: contracts.SCHEMA_VERSION,
133+ favorites: [{
134+ schemaVersion: contracts.SCHEMA_VERSION,
135+ id: "legacy-home",
136+ serviceId: "opaque:legacy-service",
137+ displayName: "Maison",
138+ stopLabel: "Châtelet",
139+ lineLabel: "1",
140+ destinationLabel: "La Défense",
141+ sortOrder: 0
142+ }],
143+ apiKey: TEST_KEY
144+ };
145+ var loaded;
146+
147+ storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord));
148+ loaded = configuration.loadConfiguration(storage);
149+
150+ assert.deepEqual(loaded, legacyRecord);
151+ assert.equal(loaded.apiKey, TEST_KEY);
152+ assert.equal(loaded.favorites.length, 1);
153+ ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) {
154+ assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false);
155+ });
156+});
129157 test("configuration opening uses the active watch language and safely falls back to English", function () {
130158 var target = harness();
131159 var state;
@@ -126,6 +126,34 @@ test("close fragment atomically stores one favorites-and-key record and exposes
126 target.xhr.instances[0].respond(200, responseFor("secret-boundary"));126 target.xhr.instances[0].respond(200, responseFor("secret-boundary"));
127 assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);127 assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
128 });128 });
129+test("a pre-presentation v1 record keeps its API key and legacy favorite on load", function () {
130+ var storage = new fakes.FakeStorage();
131+ var legacyRecord = {
132+ schemaVersion: contracts.SCHEMA_VERSION,
133+ favorites: [{
134+ schemaVersion: contracts.SCHEMA_VERSION,
135+ id: "legacy-home",
136+ serviceId: "opaque:legacy-service",
137+ displayName: "Maison",
138+ stopLabel: "Châtelet",
139+ lineLabel: "1",
140+ destinationLabel: "La Défense",
141+ sortOrder: 0
142+ }],
143+ apiKey: TEST_KEY
144+ };
145+ var loaded;
146+
147+ storage.setItem(configuration.STORAGE_KEY, JSON.stringify(legacyRecord));
148+ loaded = configuration.loadConfiguration(storage);
149+
150+ assert.deepEqual(loaded, legacyRecord);
151+ assert.equal(loaded.apiKey, TEST_KEY);
152+ assert.equal(loaded.favorites.length, 1);
153+ ["lineMode", "lineColor", "lineTextColor"].forEach(function (field) {
154+ assert.equal(Object.prototype.hasOwnProperty.call(loaded.favorites[0], field), false);
155+ });
156+});
129 test("configuration opening uses the active watch language and safely falls back to English", function () {157 test("configuration opening uses the active watch language and safely falls back to English", function () {
130 var target = harness();158 var target = harness();
131 var state;159 var state;
modified packages/companion/test/protocol.test.js +39 -0
@@ -23,6 +23,7 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac
2323 assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE);
2424 assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS);
2525 assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER);
26+ assert.deepEqual(contracts.TRANSPORT_MODE, shared.TRANSPORT_MODE);
2627 assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE);
2728 assert.deepEqual(contracts.LIMITS, shared.LIMITS);
2829 assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION);
@@ -30,6 +31,44 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac
3031 assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS);
3132 });
3233
34+test("presentation fields form an optional complete group and remain outside AppMessage", function () {
35+ var copied = contracts.copyFavorite(fixture.favorite);
36+ var legacy;
37+ var legacyCopy;
38+ var presentationFields = ["lineMode", "lineColor", "lineTextColor"];
39+ assert.deepEqual(copied, fixture.favorite);
40+ assert.equal(contracts.isFavorite(copied), true);
41+ assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineColor: "#FFBE00" })), false);
42+ assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineMode: "metro" })), false);
43+ delete copied.lineTextColor;
44+ assert.equal(contracts.isFavorite(copied), false);
45+
46+ legacy = contracts.copyFavorite(fixture.favorite);
47+ presentationFields.forEach(function (field) { delete legacy[field]; });
48+ assert.equal(contracts.isFavorite(legacy), true);
49+ assert.equal(contracts.isFavorite(Object.assign({}, legacy, { lineMode: "METRO" })), false);
50+ assert.equal(contracts.isFavorite(Object.assign({}, legacy, {
51+ lineMode: "METRO",
52+ lineColor: "#ffbe00"
53+ })), false);
54+ legacyCopy = contracts.copyFavorite(legacy);
55+ assert.deepEqual(legacyCopy, legacy);
56+ presentationFields.forEach(function (field) {
57+ assert.equal(Object.prototype.hasOwnProperty.call(legacyCopy, field), false);
58+ });
59+
60+ assert.equal(codec.encodeConfiguration(
61+ "presentation-wire",
62+ [fixture.favorite],
63+ contracts.KEY_STATUS.CONFIGURED,
64+ "en"
65+ ).some(function (message) {
66+ return Object.prototype.hasOwnProperty.call(message, "lineMode")
67+ || Object.prototype.hasOwnProperty.call(message, "lineColor")
68+ || Object.prototype.hasOwnProperty.call(message, "lineTextColor");
69+ }), false);
70+});
71+
3372 test("recorded fixture round trips through symbolic configuration and result payloads", async function () {
3473 var shared = await sharedContracts();
3574 var receiver = new shared.ProtocolReceiver();
@@ -23,6 +23,7 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac
23 assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE);23 assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE);
24 assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS);24 assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS);
25 assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER);25 assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER);
26+ assert.deepEqual(contracts.TRANSPORT_MODE, shared.TRANSPORT_MODE);
26 assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE);27 assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE);
27 assert.deepEqual(contracts.LIMITS, shared.LIMITS);28 assert.deepEqual(contracts.LIMITS, shared.LIMITS);
28 assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION);29 assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION);
@@ -30,6 +31,44 @@ test("PebbleKit constants exactly mirror the canonical alias and numeric contrac
30 assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS);31 assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS);
31 });32 });
32 33
34+test("presentation fields form an optional complete group and remain outside AppMessage", function () {
35+ var copied = contracts.copyFavorite(fixture.favorite);
36+ var legacy;
37+ var legacyCopy;
38+ var presentationFields = ["lineMode", "lineColor", "lineTextColor"];
39+ assert.deepEqual(copied, fixture.favorite);
40+ assert.equal(contracts.isFavorite(copied), true);
41+ assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineColor: "#FFBE00" })), false);
42+ assert.equal(contracts.isFavorite(Object.assign({}, copied, { lineMode: "metro" })), false);
43+ delete copied.lineTextColor;
44+ assert.equal(contracts.isFavorite(copied), false);
45+
46+ legacy = contracts.copyFavorite(fixture.favorite);
47+ presentationFields.forEach(function (field) { delete legacy[field]; });
48+ assert.equal(contracts.isFavorite(legacy), true);
49+ assert.equal(contracts.isFavorite(Object.assign({}, legacy, { lineMode: "METRO" })), false);
50+ assert.equal(contracts.isFavorite(Object.assign({}, legacy, {
51+ lineMode: "METRO",
52+ lineColor: "#ffbe00"
53+ })), false);
54+ legacyCopy = contracts.copyFavorite(legacy);
55+ assert.deepEqual(legacyCopy, legacy);
56+ presentationFields.forEach(function (field) {
57+ assert.equal(Object.prototype.hasOwnProperty.call(legacyCopy, field), false);
58+ });
59+
60+ assert.equal(codec.encodeConfiguration(
61+ "presentation-wire",
62+ [fixture.favorite],
63+ contracts.KEY_STATUS.CONFIGURED,
64+ "en"
65+ ).some(function (message) {
66+ return Object.prototype.hasOwnProperty.call(message, "lineMode")
67+ || Object.prototype.hasOwnProperty.call(message, "lineColor")
68+ || Object.prototype.hasOwnProperty.call(message, "lineTextColor");
69+ }), false);
70+});
71+
33 test("recorded fixture round trips through symbolic configuration and result payloads", async function () {72 test("recorded fixture round trips through symbolic configuration and result payloads", async function () {
34 var shared = await sharedContracts();73 var shared = await sharedContracts();
35 var receiver = new shared.ProtocolReceiver();74 var receiver = new shared.ProtocolReceiver();
added packages/config-page/assets/line-badges/attribution.json +371 -0
new file mode 100644
@@ -0,0 +1,371 @@
1+{
2+ "publisher": "Île-de-France Mobilités",
3+ "dataset": "referentiel-des-lignes",
4+ "datasetUrl": "https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/",
5+ "datasetLicense": "ODbL 1.0",
6+ "datasetLicenseUrl": "https://opendatacommons.org/licenses/odbl/1-0/",
7+ "pictogramReuseTermsUrl": "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation",
8+ "retrievedAt": "2026-09-01T18:23:34.819Z",
9+ "assets": [
10+ {
11+ "key": "METRO:1",
12+ "file": "metro-1.png",
13+ "mediaType": "image/png",
14+ "bytes": 33966,
15+ "sha256": "1e0240ea3bf8ab22b1a53eb3a783c09893f2d39e309406338ded0ca0c0d90cdc",
16+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/8e63c8180c0378991029ec5141e4c6a5"
17+ },
18+ {
19+ "key": "METRO:10",
20+ "file": "metro-10.png",
21+ "mediaType": "image/png",
22+ "bytes": 51782,
23+ "sha256": "c23ab314b5e9f1c82e63def23e15f1997e381152d2a886ee787aa139f555c726",
24+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4adaa198c6e4212d9f8bfe0e744ba857"
25+ },
26+ {
27+ "key": "METRO:11",
28+ "file": "metro-11.png",
29+ "mediaType": "image/png",
30+ "bytes": 38077,
31+ "sha256": "7c83c903307ee1dc61c09567ac435336b72bb88a1466d5d2338839bb66d744c4",
32+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/091275f9a999a893de5eb4703e1a74af"
33+ },
34+ {
35+ "key": "METRO:12",
36+ "file": "metro-12.png",
37+ "mediaType": "image/png",
38+ "bytes": 48009,
39+ "sha256": "4172b39cb56654de68d9022127432f4bc336d7df953a448a6a034c0fc823570a",
40+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/68749e9f1ce05a0a4cae6610f95eecc0"
41+ },
42+ {
43+ "key": "METRO:13",
44+ "file": "metro-13.png",
45+ "mediaType": "image/png",
46+ "bytes": 52718,
47+ "sha256": "3645c8b3265ebb6c62ba17938a336793d9835f768b7441a6e7cd9dc9805a298d",
48+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0b8a36241880cd49a78f24dbaca0433b"
49+ },
50+ {
51+ "key": "METRO:14",
52+ "file": "metro-14.png",
53+ "mediaType": "image/png",
54+ "bytes": 41282,
55+ "sha256": "87aba0a0f3f0771e03fdf4e4f1bf1ba94a0dbc9fb52e8f18834993020b293aba",
56+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/7b99df056fae694cd8d96c160696d757"
57+ },
58+ {
59+ "key": "METRO:2",
60+ "file": "metro-2.png",
61+ "mediaType": "image/png",
62+ "bytes": 43580,
63+ "sha256": "65502e82afc684865cf37ac99d3915c04ea392d93e9d6f680a442a7a5e67e58a",
64+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/cdaa0aa338d1acbd2e9d31fe21fc6eb7"
65+ },
66+ {
67+ "key": "METRO:3",
68+ "file": "metro-3.png",
69+ "mediaType": "image/png",
70+ "bytes": 48270,
71+ "sha256": "2c9ab4b4ffbafff9ac835495dc39d4cc60095523399c78d42e298717cdf05065",
72+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/418ea5fcc3bf22ac253c1aafcd531684"
73+ },
74+ {
75+ "key": "METRO:3B",
76+ "file": "metro-3b.png",
77+ "mediaType": "image/png",
78+ "bytes": 59992,
79+ "sha256": "139529c4437e29869b6769ac4ea90f9c2a9599f4ca51f63a9ff9f52fcc477b6c",
80+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/157da4ee0a222feb976fc9acc40c568b"
81+ },
82+ {
83+ "key": "METRO:4",
84+ "file": "metro-4.png",
85+ "mediaType": "image/png",
86+ "bytes": 36581,
87+ "sha256": "d0fa50af73e9d73c93d61c97c69b5416db588e25bd5f85a80479c88c55e8587c",
88+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/827ff49af57d362f6a7c5f2d1022528c"
89+ },
90+ {
91+ "key": "METRO:5",
92+ "file": "metro-5.png",
93+ "mediaType": "image/png",
94+ "bytes": 43267,
95+ "sha256": "73fa33f11b95cdd931f7061e848b3e72d6a0f66efe065f438266666497ae5a73",
96+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e1babbb11951e80ae32d076528ef2ad1"
97+ },
98+ {
99+ "key": "METRO:6",
100+ "file": "metro-6.png",
101+ "mediaType": "image/png",
102+ "bytes": 49686,
103+ "sha256": "1f48b1c52a376919ec8cfc1e16dcf28aa4c49bdac013d3e52864f8eb97c62df5",
104+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/11ba6d8a602ec0780fe8c01aa58b542d"
105+ },
106+ {
107+ "key": "METRO:7",
108+ "file": "metro-7.png",
109+ "mediaType": "image/png",
110+ "bytes": 39420,
111+ "sha256": "3134cefd49203d075280f9abbf6fdb980b15c0852aadbbfe64ce780d7a81922b",
112+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b29f81837830b3a14fb876aaecc20e9c"
113+ },
114+ {
115+ "key": "METRO:7B",
116+ "file": "metro-7b.png",
117+ "mediaType": "image/png",
118+ "bytes": 50468,
119+ "sha256": "8b07f29257e0ad136d9f72fac7f1cb0c8a240127f554badee5ce24b34727da1c",
120+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e08d6e9d99727bb7d55ffc5c44801f72"
121+ },
122+ {
123+ "key": "METRO:8",
124+ "file": "metro-8.png",
125+ "mediaType": "image/png",
126+ "bytes": 53640,
127+ "sha256": "ae9a294865058c409ec163677c1d2948b53d43220670238f3a2e692d99dfcfdc",
128+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/6dc2bda2dcd7cffbde8cf5a6c194ba59"
129+ },
130+ {
131+ "key": "METRO:9",
132+ "file": "metro-9.png",
133+ "mediaType": "image/png",
134+ "bytes": 49024,
135+ "sha256": "00a5b3708bede481c1c52f07d2cee56936da7f9cd5df5aeec4abe402433281f0",
136+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5fe858163d92581d16bcab8aba61f26a"
137+ },
138+ {
139+ "key": "RER:A",
140+ "file": "rer-a.png",
141+ "mediaType": "image/png",
142+ "bytes": 31606,
143+ "sha256": "e85f106d7abf214124472f77b4dbb4114cbb8f827b6920f103d26b4054c339bf",
144+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4049307fabd1b0db3fc129d52fbf0b76"
145+ },
146+ {
147+ "key": "RER:B",
148+ "file": "rer-b.png",
149+ "mediaType": "image/png",
150+ "bytes": 30315,
151+ "sha256": "b0e4cc26bba92f7acaf82cabf4392b3d7f12a6196adeb9b38f19ee4093e64d3d",
152+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/09417158011567b7a8165357e51e565d"
153+ },
154+ {
155+ "key": "RER:C",
156+ "file": "rer-c.png",
157+ "mediaType": "image/png",
158+ "bytes": 34780,
159+ "sha256": "f054f2a23204f2b44e5d263eb99987eaa6a922e1c4002240646f7edd18d86b24",
160+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/099e5a6dd7b15cfbd61d1e11c3d18fb6"
161+ },
162+ {
163+ "key": "RER:D",
164+ "file": "rer-d.png",
165+ "mediaType": "image/png",
166+ "bytes": 30691,
167+ "sha256": "acee82a2e5d4967cb2ecf91ed7f55ba928158524a737834b8cef9915430bb3ae",
168+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b843a92141919dab06ea02ceddfbc9c3"
169+ },
170+ {
171+ "key": "RER:E",
172+ "file": "rer-e.png",
173+ "mediaType": "image/png",
174+ "bytes": 18443,
175+ "sha256": "2b67ceb254808ddb6d80e381f0761ac8fa250dcb08dc2b68a8e481ff602e6978",
176+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/dc5bd03a80dd0c1637c29dd4a38b14bf"
177+ },
178+ {
179+ "key": "TRAM:T1",
180+ "file": "tram-t1.png",
181+ "mediaType": "image/png",
182+ "bytes": 15353,
183+ "sha256": "77b24092bf61b86ba53c21ae2982ef2791d6dd25bb5c7fbb2ed8823525f01524",
184+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/f08f0e7ac27b864d4b38160465794b0f"
185+ },
186+ {
187+ "key": "TRAM:T10",
188+ "file": "tram-t10.png",
189+ "mediaType": "image/png",
190+ "bytes": 25236,
191+ "sha256": "5924c617177fe6dcbed626f301aeaccbc0e77f940477b4e2915d6df8902730e8",
192+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0e6d747212fe8125acea04824c9de849"
193+ },
194+ {
195+ "key": "TRAM:T11",
196+ "file": "tram-t11.png",
197+ "mediaType": "image/png",
198+ "bytes": 17305,
199+ "sha256": "482f9cf8c1bab3dec0952cc7fbdf602a16a8677c2f913cdd8fa3e1764070633c",
200+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b8957b0e036e5aa94716ecb7285bea30"
201+ },
202+ {
203+ "key": "TRAM:T12",
204+ "file": "tram-t12.png",
205+ "mediaType": "image/png",
206+ "bytes": 22923,
207+ "sha256": "5a0d884b7b18483967d24f4309d0bdab72006807b30bec18b293a2ca5d6d13fa",
208+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9582f3df81d6e524d3a9065224cb75ca"
209+ },
210+ {
211+ "key": "TRAM:T13",
212+ "file": "tram-t13.png",
213+ "mediaType": "image/png",
214+ "bytes": 24950,
215+ "sha256": "8d6a3079095b36265c836d4022732dfc989f375ca874a57fd7177050ef49c5ae",
216+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/07d202b2a699910502ae61197977502d"
217+ },
218+ {
219+ "key": "TRAM:T14",
220+ "file": "tram-t14.svg",
221+ "mediaType": "image/svg+xml",
222+ "bytes": 1183,
223+ "sha256": "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25",
224+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/bfc31e2c9d3d13004836e5d5962c10e6"
225+ },
226+ {
227+ "key": "TRAM:T2",
228+ "file": "tram-t2.png",
229+ "mediaType": "image/png",
230+ "bytes": 21271,
231+ "sha256": "97ccd49e16fc98fae820ecdfb01005f196622c60c2988f74a37b706c6b00beaf",
232+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e32a597dbda794251fe47f69308c01b9"
233+ },
234+ {
235+ "key": "TRAM:T3a",
236+ "file": "tram-t3a.png",
237+ "mediaType": "image/png",
238+ "bytes": 28668,
239+ "sha256": "c686053f63ec69698fb7b0bef9a61324d49d493a0050cbf6cd48978776f513dc",
240+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9c354f0b1b03a22f88e6890606c961df"
241+ },
242+ {
243+ "key": "TRAM:T3b",
244+ "file": "tram-t3b.png",
245+ "mediaType": "image/png",
246+ "bytes": 27564,
247+ "sha256": "370599296cf062c8c04cb55eea1edf73021ba31bddb70ade0bbcc5dacd27f096",
248+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/919aee035abf4591de29f13988d63bc9"
249+ },
250+ {
251+ "key": "TRAM:T4",
252+ "file": "tram-t4.png",
253+ "mediaType": "image/png",
254+ "bytes": 17145,
255+ "sha256": "d9487770d9d2a9419963bd8b48c721ed3fc9c6e39db4432d11fadce73931c7b6",
256+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/c370af8b5511d975ebb6d62b5dc1b10f"
257+ },
258+ {
259+ "key": "TRAM:T5",
260+ "file": "tram-t5.png",
261+ "mediaType": "image/png",
262+ "bytes": 20583,
263+ "sha256": "64665e11b712aa5384fb4cc662093adf8e08c2266c3287bbe56dc80781864096",
264+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/96aee20f2627ccacff25a9bc23b96cbc"
265+ },
266+ {
267+ "key": "TRAM:T6",
268+ "file": "tram-t6.png",
269+ "mediaType": "image/png",
270+ "bytes": 24061,
271+ "sha256": "af50b1a10d0af1da7a54d439d781feceebbd1fcbfcac691197acc3d34b165cf1",
272+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/db63e7a26e27fc64926c696fe5d5a950"
273+ },
274+ {
275+ "key": "TRAM:T7",
276+ "file": "tram-t7.png",
277+ "mediaType": "image/png",
278+ "bytes": 18211,
279+ "sha256": "8266449656ade20af5d56d379a35ffe6e8247af46ff8f2aec5670daba152d21a",
280+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5568fae6d1eb504a556118a6e8bd681a"
281+ },
282+ {
283+ "key": "TRAM:T8",
284+ "file": "tram-t8.png",
285+ "mediaType": "image/png",
286+ "bytes": 25991,
287+ "sha256": "83c23f278d3e342e5955e1c3ee51bad3aa8824bfa2ed7702b5c34adab65e893e",
288+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/abdb3df6ce90364cb3a504268a29d0ba"
289+ },
290+ {
291+ "key": "TRAM:T9",
292+ "file": "tram-t9.png",
293+ "mediaType": "image/png",
294+ "bytes": 24134,
295+ "sha256": "dbc1cca5a06dec194d648705e4557133eac7dcda1993b57cda6126553eb4cfd0",
296+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ff7ac6917c6c0ef54bec9d3628571089"
297+ },
298+ {
299+ "key": "TRANSILIEN:H",
300+ "file": "transilien-h.png",
301+ "mediaType": "image/png",
302+ "bytes": 18391,
303+ "sha256": "2c7a89150337ec76aeb32c5811550045ca3251aabbd1267c22db90bcb28c3cbe",
304+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b874ac42d638fc887ea98f56789a036d"
305+ },
306+ {
307+ "key": "TRANSILIEN:J",
308+ "file": "transilien-j.png",
309+ "mediaType": "image/png",
310+ "bytes": 22673,
311+ "sha256": "a4b5bca4eca6806d70235b3ef759c3577d4fc59023cad950219ef0961a06dce5",
312+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/62e9b7ac99c792f4b7ec85b226ad008e"
313+ },
314+ {
315+ "key": "TRANSILIEN:K",
316+ "file": "transilien-k.png",
317+ "mediaType": "image/png",
318+ "bytes": 32390,
319+ "sha256": "119970ae6b664529a45ad7bd2b6e01867644dd0065b22d714be832b333a84db7",
320+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9ad21084ef4c6dce87364c6452422ef5"
321+ },
322+ {
323+ "key": "TRANSILIEN:L",
324+ "file": "transilien-l.png",
325+ "mediaType": "image/png",
326+ "bytes": 18332,
327+ "sha256": "eb7099756e43f4edb95a20e0c7fced32c50d052daef1eb6321850b8a92ce7885",
328+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ae6ca077709c1256ae09d9954aa8ece8"
329+ },
330+ {
331+ "key": "TRANSILIEN:N",
332+ "file": "transilien-n.png",
333+ "mediaType": "image/png",
334+ "bytes": 27252,
335+ "sha256": "c897905fc430689b777e9382e9f1ff7ebadb13b55107581827aabca12fe619f6",
336+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/78201d213a8fc22a54bd4bb3e26cc339"
337+ },
338+ {
339+ "key": "TRANSILIEN:P",
340+ "file": "transilien-p.png",
341+ "mediaType": "image/png",
342+ "bytes": 25981,
343+ "sha256": "2f046dff23249f4b3f69496a7381bfdcf92ed2c9b76947b99552afb30ea40de6",
344+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/144bda900e4a01a6d3eb26d8e2d9803d"
345+ },
346+ {
347+ "key": "TRANSILIEN:R",
348+ "file": "transilien-r.png",
349+ "mediaType": "image/png",
350+ "bytes": 31292,
351+ "sha256": "1a00bbe0b53e8ea8abfffa5cf34d555142b8e148a781810b3548cd5a2dfeb0d3",
352+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04ca3195d7cae3272e376492c8152900"
353+ },
354+ {
355+ "key": "TRANSILIEN:U",
356+ "file": "transilien-u.png",
357+ "mediaType": "image/png",
358+ "bytes": 26980,
359+ "sha256": "4c9cffcf66d23f710a86aa12cd2c569ffc4d7831f96475e4db4b188c8fece356",
360+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04762929c9df4a9232485fc301b1fc40"
361+ },
362+ {
363+ "key": "TRANSILIEN:V",
364+ "file": "transilien-v.png",
365+ "mediaType": "image/png",
366+ "bytes": 23917,
367+ "sha256": "b10d5a61b68fe3651affd308920088dbcfe789181f88e14f4d32e881b9477900",
368+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/2f6517d91c45d6e415c7e624abaa8cc2"
369+ }
370+ ]
371+}
new file mode 100644
@@ -0,0 +1,371 @@
1+{
2+ "publisher": "Île-de-France Mobilités",
3+ "dataset": "referentiel-des-lignes",
4+ "datasetUrl": "https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/",
5+ "datasetLicense": "ODbL 1.0",
6+ "datasetLicenseUrl": "https://opendatacommons.org/licenses/odbl/1-0/",
7+ "pictogramReuseTermsUrl": "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation",
8+ "retrievedAt": "2026-09-01T18:23:34.819Z",
9+ "assets": [
10+ {
11+ "key": "METRO:1",
12+ "file": "metro-1.png",
13+ "mediaType": "image/png",
14+ "bytes": 33966,
15+ "sha256": "1e0240ea3bf8ab22b1a53eb3a783c09893f2d39e309406338ded0ca0c0d90cdc",
16+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/8e63c8180c0378991029ec5141e4c6a5"
17+ },
18+ {
19+ "key": "METRO:10",
20+ "file": "metro-10.png",
21+ "mediaType": "image/png",
22+ "bytes": 51782,
23+ "sha256": "c23ab314b5e9f1c82e63def23e15f1997e381152d2a886ee787aa139f555c726",
24+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4adaa198c6e4212d9f8bfe0e744ba857"
25+ },
26+ {
27+ "key": "METRO:11",
28+ "file": "metro-11.png",
29+ "mediaType": "image/png",
30+ "bytes": 38077,
31+ "sha256": "7c83c903307ee1dc61c09567ac435336b72bb88a1466d5d2338839bb66d744c4",
32+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/091275f9a999a893de5eb4703e1a74af"
33+ },
34+ {
35+ "key": "METRO:12",
36+ "file": "metro-12.png",
37+ "mediaType": "image/png",
38+ "bytes": 48009,
39+ "sha256": "4172b39cb56654de68d9022127432f4bc336d7df953a448a6a034c0fc823570a",
40+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/68749e9f1ce05a0a4cae6610f95eecc0"
41+ },
42+ {
43+ "key": "METRO:13",
44+ "file": "metro-13.png",
45+ "mediaType": "image/png",
46+ "bytes": 52718,
47+ "sha256": "3645c8b3265ebb6c62ba17938a336793d9835f768b7441a6e7cd9dc9805a298d",
48+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0b8a36241880cd49a78f24dbaca0433b"
49+ },
50+ {
51+ "key": "METRO:14",
52+ "file": "metro-14.png",
53+ "mediaType": "image/png",
54+ "bytes": 41282,
55+ "sha256": "87aba0a0f3f0771e03fdf4e4f1bf1ba94a0dbc9fb52e8f18834993020b293aba",
56+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/7b99df056fae694cd8d96c160696d757"
57+ },
58+ {
59+ "key": "METRO:2",
60+ "file": "metro-2.png",
61+ "mediaType": "image/png",
62+ "bytes": 43580,
63+ "sha256": "65502e82afc684865cf37ac99d3915c04ea392d93e9d6f680a442a7a5e67e58a",
64+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/cdaa0aa338d1acbd2e9d31fe21fc6eb7"
65+ },
66+ {
67+ "key": "METRO:3",
68+ "file": "metro-3.png",
69+ "mediaType": "image/png",
70+ "bytes": 48270,
71+ "sha256": "2c9ab4b4ffbafff9ac835495dc39d4cc60095523399c78d42e298717cdf05065",
72+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/418ea5fcc3bf22ac253c1aafcd531684"
73+ },
74+ {
75+ "key": "METRO:3B",
76+ "file": "metro-3b.png",
77+ "mediaType": "image/png",
78+ "bytes": 59992,
79+ "sha256": "139529c4437e29869b6769ac4ea90f9c2a9599f4ca51f63a9ff9f52fcc477b6c",
80+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/157da4ee0a222feb976fc9acc40c568b"
81+ },
82+ {
83+ "key": "METRO:4",
84+ "file": "metro-4.png",
85+ "mediaType": "image/png",
86+ "bytes": 36581,
87+ "sha256": "d0fa50af73e9d73c93d61c97c69b5416db588e25bd5f85a80479c88c55e8587c",
88+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/827ff49af57d362f6a7c5f2d1022528c"
89+ },
90+ {
91+ "key": "METRO:5",
92+ "file": "metro-5.png",
93+ "mediaType": "image/png",
94+ "bytes": 43267,
95+ "sha256": "73fa33f11b95cdd931f7061e848b3e72d6a0f66efe065f438266666497ae5a73",
96+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e1babbb11951e80ae32d076528ef2ad1"
97+ },
98+ {
99+ "key": "METRO:6",
100+ "file": "metro-6.png",
101+ "mediaType": "image/png",
102+ "bytes": 49686,
103+ "sha256": "1f48b1c52a376919ec8cfc1e16dcf28aa4c49bdac013d3e52864f8eb97c62df5",
104+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/11ba6d8a602ec0780fe8c01aa58b542d"
105+ },
106+ {
107+ "key": "METRO:7",
108+ "file": "metro-7.png",
109+ "mediaType": "image/png",
110+ "bytes": 39420,
111+ "sha256": "3134cefd49203d075280f9abbf6fdb980b15c0852aadbbfe64ce780d7a81922b",
112+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b29f81837830b3a14fb876aaecc20e9c"
113+ },
114+ {
115+ "key": "METRO:7B",
116+ "file": "metro-7b.png",
117+ "mediaType": "image/png",
118+ "bytes": 50468,
119+ "sha256": "8b07f29257e0ad136d9f72fac7f1cb0c8a240127f554badee5ce24b34727da1c",
120+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e08d6e9d99727bb7d55ffc5c44801f72"
121+ },
122+ {
123+ "key": "METRO:8",
124+ "file": "metro-8.png",
125+ "mediaType": "image/png",
126+ "bytes": 53640,
127+ "sha256": "ae9a294865058c409ec163677c1d2948b53d43220670238f3a2e692d99dfcfdc",
128+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/6dc2bda2dcd7cffbde8cf5a6c194ba59"
129+ },
130+ {
131+ "key": "METRO:9",
132+ "file": "metro-9.png",
133+ "mediaType": "image/png",
134+ "bytes": 49024,
135+ "sha256": "00a5b3708bede481c1c52f07d2cee56936da7f9cd5df5aeec4abe402433281f0",
136+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5fe858163d92581d16bcab8aba61f26a"
137+ },
138+ {
139+ "key": "RER:A",
140+ "file": "rer-a.png",
141+ "mediaType": "image/png",
142+ "bytes": 31606,
143+ "sha256": "e85f106d7abf214124472f77b4dbb4114cbb8f827b6920f103d26b4054c339bf",
144+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/4049307fabd1b0db3fc129d52fbf0b76"
145+ },
146+ {
147+ "key": "RER:B",
148+ "file": "rer-b.png",
149+ "mediaType": "image/png",
150+ "bytes": 30315,
151+ "sha256": "b0e4cc26bba92f7acaf82cabf4392b3d7f12a6196adeb9b38f19ee4093e64d3d",
152+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/09417158011567b7a8165357e51e565d"
153+ },
154+ {
155+ "key": "RER:C",
156+ "file": "rer-c.png",
157+ "mediaType": "image/png",
158+ "bytes": 34780,
159+ "sha256": "f054f2a23204f2b44e5d263eb99987eaa6a922e1c4002240646f7edd18d86b24",
160+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/099e5a6dd7b15cfbd61d1e11c3d18fb6"
161+ },
162+ {
163+ "key": "RER:D",
164+ "file": "rer-d.png",
165+ "mediaType": "image/png",
166+ "bytes": 30691,
167+ "sha256": "acee82a2e5d4967cb2ecf91ed7f55ba928158524a737834b8cef9915430bb3ae",
168+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b843a92141919dab06ea02ceddfbc9c3"
169+ },
170+ {
171+ "key": "RER:E",
172+ "file": "rer-e.png",
173+ "mediaType": "image/png",
174+ "bytes": 18443,
175+ "sha256": "2b67ceb254808ddb6d80e381f0761ac8fa250dcb08dc2b68a8e481ff602e6978",
176+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/dc5bd03a80dd0c1637c29dd4a38b14bf"
177+ },
178+ {
179+ "key": "TRAM:T1",
180+ "file": "tram-t1.png",
181+ "mediaType": "image/png",
182+ "bytes": 15353,
183+ "sha256": "77b24092bf61b86ba53c21ae2982ef2791d6dd25bb5c7fbb2ed8823525f01524",
184+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/f08f0e7ac27b864d4b38160465794b0f"
185+ },
186+ {
187+ "key": "TRAM:T10",
188+ "file": "tram-t10.png",
189+ "mediaType": "image/png",
190+ "bytes": 25236,
191+ "sha256": "5924c617177fe6dcbed626f301aeaccbc0e77f940477b4e2915d6df8902730e8",
192+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/0e6d747212fe8125acea04824c9de849"
193+ },
194+ {
195+ "key": "TRAM:T11",
196+ "file": "tram-t11.png",
197+ "mediaType": "image/png",
198+ "bytes": 17305,
199+ "sha256": "482f9cf8c1bab3dec0952cc7fbdf602a16a8677c2f913cdd8fa3e1764070633c",
200+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b8957b0e036e5aa94716ecb7285bea30"
201+ },
202+ {
203+ "key": "TRAM:T12",
204+ "file": "tram-t12.png",
205+ "mediaType": "image/png",
206+ "bytes": 22923,
207+ "sha256": "5a0d884b7b18483967d24f4309d0bdab72006807b30bec18b293a2ca5d6d13fa",
208+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9582f3df81d6e524d3a9065224cb75ca"
209+ },
210+ {
211+ "key": "TRAM:T13",
212+ "file": "tram-t13.png",
213+ "mediaType": "image/png",
214+ "bytes": 24950,
215+ "sha256": "8d6a3079095b36265c836d4022732dfc989f375ca874a57fd7177050ef49c5ae",
216+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/07d202b2a699910502ae61197977502d"
217+ },
218+ {
219+ "key": "TRAM:T14",
220+ "file": "tram-t14.svg",
221+ "mediaType": "image/svg+xml",
222+ "bytes": 1183,
223+ "sha256": "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25",
224+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/bfc31e2c9d3d13004836e5d5962c10e6"
225+ },
226+ {
227+ "key": "TRAM:T2",
228+ "file": "tram-t2.png",
229+ "mediaType": "image/png",
230+ "bytes": 21271,
231+ "sha256": "97ccd49e16fc98fae820ecdfb01005f196622c60c2988f74a37b706c6b00beaf",
232+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/e32a597dbda794251fe47f69308c01b9"
233+ },
234+ {
235+ "key": "TRAM:T3a",
236+ "file": "tram-t3a.png",
237+ "mediaType": "image/png",
238+ "bytes": 28668,
239+ "sha256": "c686053f63ec69698fb7b0bef9a61324d49d493a0050cbf6cd48978776f513dc",
240+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9c354f0b1b03a22f88e6890606c961df"
241+ },
242+ {
243+ "key": "TRAM:T3b",
244+ "file": "tram-t3b.png",
245+ "mediaType": "image/png",
246+ "bytes": 27564,
247+ "sha256": "370599296cf062c8c04cb55eea1edf73021ba31bddb70ade0bbcc5dacd27f096",
248+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/919aee035abf4591de29f13988d63bc9"
249+ },
250+ {
251+ "key": "TRAM:T4",
252+ "file": "tram-t4.png",
253+ "mediaType": "image/png",
254+ "bytes": 17145,
255+ "sha256": "d9487770d9d2a9419963bd8b48c721ed3fc9c6e39db4432d11fadce73931c7b6",
256+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/c370af8b5511d975ebb6d62b5dc1b10f"
257+ },
258+ {
259+ "key": "TRAM:T5",
260+ "file": "tram-t5.png",
261+ "mediaType": "image/png",
262+ "bytes": 20583,
263+ "sha256": "64665e11b712aa5384fb4cc662093adf8e08c2266c3287bbe56dc80781864096",
264+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/96aee20f2627ccacff25a9bc23b96cbc"
265+ },
266+ {
267+ "key": "TRAM:T6",
268+ "file": "tram-t6.png",
269+ "mediaType": "image/png",
270+ "bytes": 24061,
271+ "sha256": "af50b1a10d0af1da7a54d439d781feceebbd1fcbfcac691197acc3d34b165cf1",
272+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/db63e7a26e27fc64926c696fe5d5a950"
273+ },
274+ {
275+ "key": "TRAM:T7",
276+ "file": "tram-t7.png",
277+ "mediaType": "image/png",
278+ "bytes": 18211,
279+ "sha256": "8266449656ade20af5d56d379a35ffe6e8247af46ff8f2aec5670daba152d21a",
280+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/5568fae6d1eb504a556118a6e8bd681a"
281+ },
282+ {
283+ "key": "TRAM:T8",
284+ "file": "tram-t8.png",
285+ "mediaType": "image/png",
286+ "bytes": 25991,
287+ "sha256": "83c23f278d3e342e5955e1c3ee51bad3aa8824bfa2ed7702b5c34adab65e893e",
288+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/abdb3df6ce90364cb3a504268a29d0ba"
289+ },
290+ {
291+ "key": "TRAM:T9",
292+ "file": "tram-t9.png",
293+ "mediaType": "image/png",
294+ "bytes": 24134,
295+ "sha256": "dbc1cca5a06dec194d648705e4557133eac7dcda1993b57cda6126553eb4cfd0",
296+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ff7ac6917c6c0ef54bec9d3628571089"
297+ },
298+ {
299+ "key": "TRANSILIEN:H",
300+ "file": "transilien-h.png",
301+ "mediaType": "image/png",
302+ "bytes": 18391,
303+ "sha256": "2c7a89150337ec76aeb32c5811550045ca3251aabbd1267c22db90bcb28c3cbe",
304+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/b874ac42d638fc887ea98f56789a036d"
305+ },
306+ {
307+ "key": "TRANSILIEN:J",
308+ "file": "transilien-j.png",
309+ "mediaType": "image/png",
310+ "bytes": 22673,
311+ "sha256": "a4b5bca4eca6806d70235b3ef759c3577d4fc59023cad950219ef0961a06dce5",
312+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/62e9b7ac99c792f4b7ec85b226ad008e"
313+ },
314+ {
315+ "key": "TRANSILIEN:K",
316+ "file": "transilien-k.png",
317+ "mediaType": "image/png",
318+ "bytes": 32390,
319+ "sha256": "119970ae6b664529a45ad7bd2b6e01867644dd0065b22d714be832b333a84db7",
320+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/9ad21084ef4c6dce87364c6452422ef5"
321+ },
322+ {
323+ "key": "TRANSILIEN:L",
324+ "file": "transilien-l.png",
325+ "mediaType": "image/png",
326+ "bytes": 18332,
327+ "sha256": "eb7099756e43f4edb95a20e0c7fced32c50d052daef1eb6321850b8a92ce7885",
328+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/ae6ca077709c1256ae09d9954aa8ece8"
329+ },
330+ {
331+ "key": "TRANSILIEN:N",
332+ "file": "transilien-n.png",
333+ "mediaType": "image/png",
334+ "bytes": 27252,
335+ "sha256": "c897905fc430689b777e9382e9f1ff7ebadb13b55107581827aabca12fe619f6",
336+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/78201d213a8fc22a54bd4bb3e26cc339"
337+ },
338+ {
339+ "key": "TRANSILIEN:P",
340+ "file": "transilien-p.png",
341+ "mediaType": "image/png",
342+ "bytes": 25981,
343+ "sha256": "2f046dff23249f4b3f69496a7381bfdcf92ed2c9b76947b99552afb30ea40de6",
344+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/144bda900e4a01a6d3eb26d8e2d9803d"
345+ },
346+ {
347+ "key": "TRANSILIEN:R",
348+ "file": "transilien-r.png",
349+ "mediaType": "image/png",
350+ "bytes": 31292,
351+ "sha256": "1a00bbe0b53e8ea8abfffa5cf34d555142b8e148a781810b3548cd5a2dfeb0d3",
352+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04ca3195d7cae3272e376492c8152900"
353+ },
354+ {
355+ "key": "TRANSILIEN:U",
356+ "file": "transilien-u.png",
357+ "mediaType": "image/png",
358+ "bytes": 26980,
359+ "sha256": "4c9cffcf66d23f710a86aa12cd2c569ffc4d7831f96475e4db4b188c8fece356",
360+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/04762929c9df4a9232485fc301b1fc40"
361+ },
362+ {
363+ "key": "TRANSILIEN:V",
364+ "file": "transilien-v.png",
365+ "mediaType": "image/png",
366+ "bytes": 23917,
367+ "sha256": "b10d5a61b68fe3651affd308920088dbcfe789181f88e14f4d32e881b9477900",
368+ "source": "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/referentiel-des-lignes/files/2f6517d91c45d6e415c7e624abaa8cc2"
369+ }
370+ ]
371+}
added packages/config-page/assets/line-badges/metro-1.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-1.png differ
added packages/config-page/assets/line-badges/metro-10.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-10.png differ
added packages/config-page/assets/line-badges/metro-11.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-11.png differ
added packages/config-page/assets/line-badges/metro-12.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-12.png differ
added packages/config-page/assets/line-badges/metro-13.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-13.png differ
added packages/config-page/assets/line-badges/metro-14.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-14.png differ
added packages/config-page/assets/line-badges/metro-2.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-2.png differ
added packages/config-page/assets/line-badges/metro-3.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-3.png differ
added packages/config-page/assets/line-badges/metro-3b.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-3b.png differ
added packages/config-page/assets/line-badges/metro-4.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-4.png differ
added packages/config-page/assets/line-badges/metro-5.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-5.png differ
added packages/config-page/assets/line-badges/metro-6.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-6.png differ
added packages/config-page/assets/line-badges/metro-7.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-7.png differ
added packages/config-page/assets/line-badges/metro-7b.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-7b.png differ
added packages/config-page/assets/line-badges/metro-8.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-8.png differ
added packages/config-page/assets/line-badges/metro-9.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/metro-9.png differ
added packages/config-page/assets/line-badges/rer-a.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/rer-a.png differ
added packages/config-page/assets/line-badges/rer-b.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/rer-b.png differ
added packages/config-page/assets/line-badges/rer-c.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/rer-c.png differ
added packages/config-page/assets/line-badges/rer-d.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/rer-d.png differ
added packages/config-page/assets/line-badges/rer-e.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/rer-e.png differ
added packages/config-page/assets/line-badges/tram-t1.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t1.png differ
added packages/config-page/assets/line-badges/tram-t10.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t10.png differ
added packages/config-page/assets/line-badges/tram-t11.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t11.png differ
added packages/config-page/assets/line-badges/tram-t12.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t12.png differ
added packages/config-page/assets/line-badges/tram-t13.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t13.png differ
added packages/config-page/assets/line-badges/tram-t14.svg +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24.01">
3+ <defs>
4+ <style>
5+ .cls-1 {
6+ fill: #00a092;
7+ }
8+
9+ .cls-2 {
10+ fill: #fff;
11+ }
12+ </style>
13+ </defs>
14+ <path class="cls-2" d="M2.18,24.01c-1.2,0-2.18-.98-2.18-2.18V2.18C0,.98.98,0,2.18,0h19.64c1.2,0,2.18.98,2.18,2.18v19.64c0,1.2-.98,2.18-2.18,2.18H2.18Z"/>
15+ <g id="Layer_1-2" data-name="Layer 1">
16+ <g>
17+ <path class="cls-1" d="M21.83,4.34H2.19c-.6,0-1.09-.49-1.09-1.09v-1.09c0-.6.49-1.09,1.09-1.09h19.64c.6,0,1.09.49,1.09,1.09v1.09c0,.6-.49,1.09-1.09,1.09M22.91,21.81v-1.09c0-.6-.49-1.09-1.09-1.09H2.19c-.6,0-1.09.49-1.09,1.09v1.09c0,.6.49,1.09,1.09,1.09h19.64c.6,0,1.09-.49,1.09-1.09"/>
18+ <g>
19+ <polygon points="6.25 9.34 6.25 16.24 4.4 16.24 4.4 9.34 1.81 9.34 1.81 7.83 8.84 7.83 8.84 9.34 6.25 9.34"/>
20+ <path d="M13.03,16.24v-6.61c-.51.41-1.32.77-1.88,1.05l-.57-1.29c.88-.37,1.94-.93,2.77-1.56h1.46v8.41h-1.79ZM21.21,14.48v1.75h-1.67v-1.75h-3.79v-1.56l3.26-5.1h2.19v5.34h.99v1.31h-.99ZM19.54,9.31h-.02c-.04.14-.28.65-.61,1.18l-1.72,2.69h2.35v-3.87Z"/>
21+ </g>
22+ </g>
23+ </g>
24+</svg>
\ No newline at end of file
new file mode 100644
@@ -0,0 +1,24 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24.01">
3+ <defs>
4+ <style>
5+ .cls-1 {
6+ fill: #00a092;
7+ }
8+
9+ .cls-2 {
10+ fill: #fff;
11+ }
12+ </style>
13+ </defs>
14+ <path class="cls-2" d="M2.18,24.01c-1.2,0-2.18-.98-2.18-2.18V2.18C0,.98.98,0,2.18,0h19.64c1.2,0,2.18.98,2.18,2.18v19.64c0,1.2-.98,2.18-2.18,2.18H2.18Z"/>
15+ <g id="Layer_1-2" data-name="Layer 1">
16+ <g>
17+ <path class="cls-1" d="M21.83,4.34H2.19c-.6,0-1.09-.49-1.09-1.09v-1.09c0-.6.49-1.09,1.09-1.09h19.64c.6,0,1.09.49,1.09,1.09v1.09c0,.6-.49,1.09-1.09,1.09M22.91,21.81v-1.09c0-.6-.49-1.09-1.09-1.09H2.19c-.6,0-1.09.49-1.09,1.09v1.09c0,.6.49,1.09,1.09,1.09h19.64c.6,0,1.09-.49,1.09-1.09"/>
18+ <g>
19+ <polygon points="6.25 9.34 6.25 16.24 4.4 16.24 4.4 9.34 1.81 9.34 1.81 7.83 8.84 7.83 8.84 9.34 6.25 9.34"/>
20+ <path d="M13.03,16.24v-6.61c-.51.41-1.32.77-1.88,1.05l-.57-1.29c.88-.37,1.94-.93,2.77-1.56h1.46v8.41h-1.79ZM21.21,14.48v1.75h-1.67v-1.75h-3.79v-1.56l3.26-5.1h2.19v5.34h.99v1.31h-.99ZM19.54,9.31h-.02c-.04.14-.28.65-.61,1.18l-1.72,2.69h2.35v-3.87Z"/>
21+ </g>
22+ </g>
23+ </g>
24+</svg>
\ No newline at end of file\ No newline at end of file
added packages/config-page/assets/line-badges/tram-t2.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t2.png differ
added packages/config-page/assets/line-badges/tram-t3a.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3a.png differ
added packages/config-page/assets/line-badges/tram-t3b.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t3b.png differ
added packages/config-page/assets/line-badges/tram-t4.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t4.png differ
added packages/config-page/assets/line-badges/tram-t5.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t5.png differ
added packages/config-page/assets/line-badges/tram-t6.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t6.png differ
added packages/config-page/assets/line-badges/tram-t7.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t7.png differ
added packages/config-page/assets/line-badges/tram-t8.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t8.png differ
added packages/config-page/assets/line-badges/tram-t9.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/tram-t9.png differ
added packages/config-page/assets/line-badges/transilien-h.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-h.png differ
added packages/config-page/assets/line-badges/transilien-j.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-j.png differ
added packages/config-page/assets/line-badges/transilien-k.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-k.png differ
added packages/config-page/assets/line-badges/transilien-l.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-l.png differ
added packages/config-page/assets/line-badges/transilien-n.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-n.png differ
added packages/config-page/assets/line-badges/transilien-p.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-p.png differ
added packages/config-page/assets/line-badges/transilien-r.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-r.png differ
added packages/config-page/assets/line-badges/transilien-u.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-u.png differ
added packages/config-page/assets/line-badges/transilien-v.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differBinary files /dev/null and b/packages/config-page/assets/line-badges/transilien-v.png differ
modified packages/config-page/index.html +109 -12
@@ -2,37 +2,134 @@
22 <html lang="en">
33 <head>
44 <meta charset="utf-8">
5- <meta name="viewport" content="width=device-width, initial-scale=1">
5+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
66 <meta name="referrer" content="no-referrer">
7- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
7+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
88 <title>Lapin Futé</title>
99 <link rel="stylesheet" href="./styles/config-page.css">
1010 </head>
1111 <body>
12- <main>
13- <h1 id="page-title">Lapin Futé</h1>
12+ <header class="masthead">
13+ <div class="route-mark" aria-hidden="true">LF</div>
14+ <div>
15+ <h1 id="page-title">Lapin Futé</h1>
16+ <p id="intro"></p>
17+ </div>
18+ </header>
1419
20+ <main>
21+ <div id="config-view">
1522 <section aria-labelledby="key-title">
16- <h2 id="key-title">API key</h2>
23+ <div class="section-heading">
24+ <span class="step" aria-hidden="true">01</span>
25+ <h2 id="key-title">PRIM access</h2>
26+ </div>
1727 <p class="status" id="key-status"></p>
28+ <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p>
1829 <label class="field-label" for="key-input" id="key-label"></label>
1930 <div class="key-row">
20- <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false">
21- <button type="button" id="key-toggle" aria-pressed="false">Show</button>
31+ <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
32+ <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>
2233 </div>
2334 <p class="error" id="key-error" role="alert" hidden></p>
24- <p class="pending" id="key-pending" hidden></p>
25- <button type="button" id="key-remove" class="danger" hidden>Remove key</button>
35+ <p class="pending" id="key-pending" role="status" hidden></p>
36+ <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
2637 </section>
2738
2839 <section aria-labelledby="favorites-title">
29- <h2 id="favorites-title">Favorites</h2>
40+ <div class="section-heading">
41+ <span class="step" aria-hidden="true">02</span>
42+ <h2 id="favorites-title">Favorite departures</h2>
43+ </div>
3044 <p class="status" id="favorites-empty" hidden></p>
31- <ul id="favorites-list"></ul>
45+ <ol id="favorites-list" class="favorites"></ol>
46+ </section>
47+
48+ <section aria-labelledby="add-title" id="add-section">
49+ <div class="section-heading">
50+ <span class="step" aria-hidden="true">03</span>
51+ <h2 id="add-title">Add a favorite</h2>
52+ </div>
53+ <label class="field-label" for="place-search" id="search-label"></label>
54+ <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false">
55+ <p class="support" id="search-hint"></p>
56+ <p id="catalog-status" class="status" role="status" aria-live="polite"></p>
57+ <ul id="place-results" class="choice-list"></ul>
58+
59+ <div id="service-step" hidden>
60+ <label class="field-label" for="service-select" id="services-label"></label>
61+ <select id="service-select"></select>
62+ </div>
63+
64+ <div id="preview" class="preview" hidden>
65+ <p class="eyebrow" id="preview-title"></p>
66+ <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p>
67+ <p id="preview-destination" class="destination"></p>
68+ <p id="preview-departures" class="recorded-preview"></p>
69+ <label class="field-label" for="favorite-name" id="favorite-name-label"></label>
70+ <input id="favorite-name" type="text" autocomplete="off" maxlength="96">
71+ <button type="button" id="favorite-add" class="secondary"></button>
72+ </div>
3273 </section>
74+ </div>
3375
34- <button type="button" id="save" class="primary">Save</button>
76+ <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
77+ <button type="button" id="about-back" class="quiet"></button>
78+ <div class="section-heading about-heading">
79+ <span class="step" aria-hidden="true">LF</span>
80+ <h2 id="about-title"></h2>
81+ </div>
82+
83+ <div id="about-en" lang="en">
84+ <p class="about-version">Version 0.1.0</p>
85+ <h3>Publisher and contact</h3>
86+ <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
87+ <p><strong>Publication director:</strong> Anthodev.</p>
88+ <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
89+ <h3>Hosting</h3>
90+ <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p>
91+ <h3>Privacy and data flow</h3>
92+ <p>This app page sets no cookies and uses no analytics or telemetry.</p>
93+ <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
95+ <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
96+ <h3>Data and credits</h3>
97+ <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p>
98+ <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p>
99+ <h3>Independence and source</h3>
100+ <p>Lapin Futé is an independent personal project. It is not affiliated with, endorsed by or sponsored by Île-de-France Mobilités, PRIM, ILICO, Pebble or their respective owners. Cited names and trademarks belong to their owners.</p>
101+ <p>Source code: <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p>
102+ </div>
103+
104+ <div id="about-fr" lang="fr" hidden>
105+ <p class="about-version">Version 0.1.0</p>
106+ <h3>Édition et contact</h3>
107+ <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
108+ <p><strong>Directeur de la publication :</strong> Anthodev.</p>
109+ <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
110+ <h3>Hébergement</h3>
111+ <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p>
112+ <h3>Vie privée et circulation des données</h3>
113+ <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114+ <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
115+ <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116+ <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117+ <h3>Données et crédits</h3>
118+ <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p>
119+ <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p>
120+ <h3>Indépendance et code source</h3>
121+ <p>Lapin Futé est un projet personnel indépendant, sans affiliation, approbation ni parrainage d’Île-de-France Mobilités, de PRIM, d’ILICO, de Pebble ou de leurs ayants droit. Les noms et marques cités appartiennent à leurs titulaires.</p>
122+ <p>Code source : <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p>
123+ </div>
124+ </section>
35125 </main>
126+
127+ <footer id="config-footer">
128+ <div class="footer-actions">
129+ <button type="button" id="about-open" class="quiet"></button>
130+ <button type="button" id="save" class="primary">Save settings</button>
131+ </div>
132+ </footer>
36133 <noscript><p class="status">This page requires JavaScript.</p></noscript>
37134 <script type="module" src="./src/config-page.js"></script>
38135 </body>
@@ -2,37 +2,134 @@
2 <html lang="en">2 <html lang="en">
3 <head>3 <head>
4 <meta charset="utf-8">4 <meta charset="utf-8">
5- <meta name="viewport" content="width=device-width, initial-scale=1">5+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 <meta name="referrer" content="no-referrer">6 <meta name="referrer" content="no-referrer">
7- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">7+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; object-src 'none'">
8 <title>Lapin Futé</title>8 <title>Lapin Futé</title>
9 <link rel="stylesheet" href="./styles/config-page.css">9 <link rel="stylesheet" href="./styles/config-page.css">
10 </head>10 </head>
11 <body>11 <body>
12- <main>12+ <header class="masthead">
13- <h1 id="page-title">Lapin Futé</h1>13+ <div class="route-mark" aria-hidden="true">LF</div>
14+ <div>
15+ <h1 id="page-title">Lapin Futé</h1>
16+ <p id="intro"></p>
17+ </div>
18+ </header>
14 19
20+ <main>
21+ <div id="config-view">
15 <section aria-labelledby="key-title">22 <section aria-labelledby="key-title">
16- <h2 id="key-title">API key</h2>23+ <div class="section-heading">
24+ <span class="step" aria-hidden="true">01</span>
25+ <h2 id="key-title">PRIM access</h2>
26+ </div>
17 <p class="status" id="key-status"></p>27 <p class="status" id="key-status"></p>
28+ <p class="support"><span id="key-explanation"></span> <a id="key-link" href="https://prim.iledefrance-mobilites.fr/en/aide-et-contact/documentation/donnees-disponibles/api/generer-un-jeton-pour-acceder-aux-api" target="_blank" rel="noopener noreferrer"></a></p>
18 <label class="field-label" for="key-input" id="key-label"></label>29 <label class="field-label" for="key-input" id="key-label"></label>
19 <div class="key-row">30 <div class="key-row">
20- <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false">31+ <input id="key-input" type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
21- <button type="button" id="key-toggle" aria-pressed="false">Show</button>32+ <button type="button" id="key-toggle" class="quiet" aria-pressed="false">Show</button>
22 </div>33 </div>
23 <p class="error" id="key-error" role="alert" hidden></p>34 <p class="error" id="key-error" role="alert" hidden></p>
24- <p class="pending" id="key-pending" hidden></p>35+ <p class="pending" id="key-pending" role="status" hidden></p>
25- <button type="button" id="key-remove" class="danger" hidden>Remove key</button>36+ <button type="button" id="key-remove" class="danger quiet" hidden>Remove key</button>
26 </section>37 </section>
27 38
28 <section aria-labelledby="favorites-title">39 <section aria-labelledby="favorites-title">
29- <h2 id="favorites-title">Favorites</h2>40+ <div class="section-heading">
41+ <span class="step" aria-hidden="true">02</span>
42+ <h2 id="favorites-title">Favorite departures</h2>
43+ </div>
30 <p class="status" id="favorites-empty" hidden></p>44 <p class="status" id="favorites-empty" hidden></p>
31- <ul id="favorites-list"></ul>45+ <ol id="favorites-list" class="favorites"></ol>
46+ </section>
47+
48+ <section aria-labelledby="add-title" id="add-section">
49+ <div class="section-heading">
50+ <span class="step" aria-hidden="true">03</span>
51+ <h2 id="add-title">Add a favorite</h2>
52+ </div>
53+ <label class="field-label" for="place-search" id="search-label"></label>
54+ <input id="place-search" type="search" inputmode="search" autocomplete="off" autocapitalize="sentences" spellcheck="false">
55+ <p class="support" id="search-hint"></p>
56+ <p id="catalog-status" class="status" role="status" aria-live="polite"></p>
57+ <ul id="place-results" class="choice-list"></ul>
58+
59+ <div id="service-step" hidden>
60+ <label class="field-label" for="service-select" id="services-label"></label>
61+ <select id="service-select"></select>
62+ </div>
63+
64+ <div id="preview" class="preview" hidden>
65+ <p class="eyebrow" id="preview-title"></p>
66+ <p><span id="preview-line" class="line-chip"></span> <strong id="preview-stop"></strong></p>
67+ <p id="preview-destination" class="destination"></p>
68+ <p id="preview-departures" class="recorded-preview"></p>
69+ <label class="field-label" for="favorite-name" id="favorite-name-label"></label>
70+ <input id="favorite-name" type="text" autocomplete="off" maxlength="96">
71+ <button type="button" id="favorite-add" class="secondary"></button>
72+ </div>
32 </section>73 </section>
74+ </div>
33 75
34- <button type="button" id="save" class="primary">Save</button>76+ <section id="about-view" class="about-view" aria-labelledby="about-title" hidden>
77+ <button type="button" id="about-back" class="quiet"></button>
78+ <div class="section-heading about-heading">
79+ <span class="step" aria-hidden="true">LF</span>
80+ <h2 id="about-title"></h2>
81+ </div>
82+
83+ <div id="about-en" lang="en">
84+ <p class="about-version">Version 0.1.0</p>
85+ <h3>Publisher and contact</h3>
86+ <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
87+ <p><strong>Publication director:</strong> Anthodev.</p>
88+ <p>Public contact: <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
89+ <h3>Hosting</h3>
90+ <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, USA. Electronic support: <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">GitHub Support</a>.</p>
91+ <h3>Privacy and data flow</h3>
92+ <p>This app page sets no cookies and uses no analytics or telemetry.</p>
93+ <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
95+ <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
96+ <h3>Data and credits</h3>
97+ <p>Transport line names and colors come from the Île-de-France Mobilités <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> database, licensed under <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Official line pictograms are © Île-de-France Mobilités and are reused subject to the <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">IDFM/PRIM terms of use</a>.</p>
98+ <p>Real-time departures come from the Île-de-France Mobilités <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM platform</a> using your personal key and remain subject to the terms and licences published by PRIM.</p>
99+ <h3>Independence and source</h3>
100+ <p>Lapin Futé is an independent personal project. It is not affiliated with, endorsed by or sponsored by Île-de-France Mobilités, PRIM, ILICO, Pebble or their respective owners. Cited names and trademarks belong to their owners.</p>
101+ <p>Source code: <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p>
102+ </div>
103+
104+ <div id="about-fr" lang="fr" hidden>
105+ <p class="about-version">Version 0.1.0</p>
106+ <h3>Édition et contact</h3>
107+ <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
108+ <p><strong>Directeur de la publication :</strong> Anthodev.</p>
109+ <p>Contact public : <a href="https://github.com/Anthodev/lapin-fute/issues" target="_blank" rel="noopener noreferrer">GitHub Issues</a>.</p>
110+ <h3>Hébergement</h3>
111+ <p>GitHub, Inc., 88 Colin P. Kelly Jr. St., San Francisco, CA 94107, États-Unis. Assistance électronique : <a href="https://support.github.com/contact" target="_blank" rel="noopener noreferrer">support GitHub</a>.</p>
112+ <h3>Vie privée et circulation des données</h3>
113+ <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114+ <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
115+ <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116+ <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117+ <h3>Données et crédits</h3>
118+ <p>Les noms et couleurs des lignes proviennent de la base <a href="https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/" target="_blank" rel="noopener noreferrer">Référentiel des lignes</a> d’Île-de-France Mobilités, sous licence <a href="https://opendatacommons.org/licenses/odbl/1-0/" target="_blank" rel="noopener noreferrer">ODbL 1.0</a>. Les pictogrammes officiels des lignes sont © Île-de-France Mobilités et réutilisés selon les <a href="https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation" target="_blank" rel="noopener noreferrer">conditions d’utilisation IDFM/PRIM</a>.</p>
119+ <p>Les départs en temps réel proviennent de la plateforme <a href="https://prim.iledefrance-mobilites.fr/" target="_blank" rel="noopener noreferrer">PRIM</a> d’Île-de-France Mobilités au moyen de votre clé personnelle. Ils restent soumis aux conditions et licences publiées par PRIM.</p>
120+ <h3>Indépendance et code source</h3>
121+ <p>Lapin Futé est un projet personnel indépendant, sans affiliation, approbation ni parrainage d’Île-de-France Mobilités, de PRIM, d’ILICO, de Pebble ou de leurs ayants droit. Les noms et marques cités appartiennent à leurs titulaires.</p>
122+ <p>Code source : <a href="https://github.com/Anthodev/lapin-fute" target="_blank" rel="noopener noreferrer">github.com/Anthodev/lapin-fute</a>.</p>
123+ </div>
124+ </section>
35 </main>125 </main>
126+
127+ <footer id="config-footer">
128+ <div class="footer-actions">
129+ <button type="button" id="about-open" class="quiet"></button>
130+ <button type="button" id="save" class="primary">Save settings</button>
131+ </div>
132+ </footer>
36 <noscript><p class="status">This page requires JavaScript.</p></noscript>133 <noscript><p class="status">This page requires JavaScript.</p></noscript>
37 <script type="module" src="./src/config-page.js"></script>134 <script type="module" src="./src/config-page.js"></script>
38 </body>135 </body>
added packages/config-page/src/catalog-client.js +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+import {
2+ LIMITS,
3+ isPlaceSearchResult,
4+ isServiceOptionsResult,
5+} from "./config-core.js";
6+
7+export const SEARCH_DEBOUNCE_MS = 300;
8+
9+export class CatalogClientError extends Error {
10+ constructor(code) {
11+ super(code);
12+ this.name = "CatalogClientError";
13+ this.code = code;
14+ }
15+}
16+
17+function abortError() {
18+ return new DOMException("Superseded", "AbortError");
19+}
20+
21+async function responseJson(response) {
22+ if (!response.ok) {
23+ throw new CatalogClientError(response.status === 404 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
24+ }
25+ try {
26+ return await response.json();
27+ } catch {
28+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
29+ }
30+}
31+
32+export function createCatalogClient({
33+ fetchImpl = globalThis.fetch,
34+ setTimer = globalThis.setTimeout,
35+ clearTimer = globalThis.clearTimeout,
36+} = {}) {
37+ let timer = null;
38+ let searchController = null;
39+
40+ function cancelSearch() {
41+ if (timer !== null) {
42+ clearTimer(timer);
43+ timer = null;
44+ }
45+ if (searchController !== null) {
46+ searchController.abort();
47+ searchController = null;
48+ }
49+ }
50+
51+ function searchPlaces(query) {
52+ cancelSearch();
53+ const normalized = typeof query === "string" ? query.trim() : "";
54+ if (Array.from(normalized).length < LIMITS.catalogQueryMinCharacters) {
55+ return Promise.resolve([]);
56+ }
57+ if (Array.from(normalized).length > LIMITS.catalogQueryMaxCharacters) {
58+ return Promise.reject(new CatalogClientError("INVALID_QUERY"));
59+ }
60+
61+ const controller = new AbortController();
62+ searchController = controller;
63+ return new Promise((resolve, reject) => {
64+ controller.signal.addEventListener("abort", () => reject(abortError()), { once: true });
65+ timer = setTimer(async () => {
66+ timer = null;
67+ if (controller.signal.aborted) return;
68+ try {
69+ const response = await fetchImpl(
70+ `/api/catalog/places?q=${encodeURIComponent(normalized)}`,
71+ { signal: controller.signal, credentials: "omit", headers: { accept: "application/json" } },
72+ );
73+ const body = await responseJson(response);
74+ if (!isPlaceSearchResult(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
75+ if (!controller.signal.aborted) resolve(body.places);
76+ } catch (error) {
77+ if (!controller.signal.aborted) reject(error instanceof CatalogClientError
78+ ? error
79+ : new CatalogClientError("BACKEND_UNAVAILABLE"));
80+ } finally {
81+ if (searchController === controller) searchController = null;
82+ }
83+ }, SEARCH_DEBOUNCE_MS);
84+ });
85+ }
86+
87+ async function listServices(placeId, signal) {
88+ const response = await fetchImpl(
89+ `/api/catalog/places/${encodeURIComponent(placeId)}/services`,
90+ { signal, credentials: "omit", headers: { accept: "application/json" } },
91+ );
92+ const body = await responseJson(response);
93+ if (!isServiceOptionsResult(body, placeId)) throw new CatalogClientError("INVALID_SERVICE");
94+ return body.services;
95+ }
96+
97+ return { cancelSearch, searchPlaces, listServices };
98+}
new file mode 100644
@@ -0,0 +1,98 @@
1+import {
2+ LIMITS,
3+ isPlaceSearchResult,
4+ isServiceOptionsResult,
5+} from "./config-core.js";
6+
7+export const SEARCH_DEBOUNCE_MS = 300;
8+
9+export class CatalogClientError extends Error {
10+ constructor(code) {
11+ super(code);
12+ this.name = "CatalogClientError";
13+ this.code = code;
14+ }
15+}
16+
17+function abortError() {
18+ return new DOMException("Superseded", "AbortError");
19+}
20+
21+async function responseJson(response) {
22+ if (!response.ok) {
23+ throw new CatalogClientError(response.status === 404 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
24+ }
25+ try {
26+ return await response.json();
27+ } catch {
28+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
29+ }
30+}
31+
32+export function createCatalogClient({
33+ fetchImpl = globalThis.fetch,
34+ setTimer = globalThis.setTimeout,
35+ clearTimer = globalThis.clearTimeout,
36+} = {}) {
37+ let timer = null;
38+ let searchController = null;
39+
40+ function cancelSearch() {
41+ if (timer !== null) {
42+ clearTimer(timer);
43+ timer = null;
44+ }
45+ if (searchController !== null) {
46+ searchController.abort();
47+ searchController = null;
48+ }
49+ }
50+
51+ function searchPlaces(query) {
52+ cancelSearch();
53+ const normalized = typeof query === "string" ? query.trim() : "";
54+ if (Array.from(normalized).length < LIMITS.catalogQueryMinCharacters) {
55+ return Promise.resolve([]);
56+ }
57+ if (Array.from(normalized).length > LIMITS.catalogQueryMaxCharacters) {
58+ return Promise.reject(new CatalogClientError("INVALID_QUERY"));
59+ }
60+
61+ const controller = new AbortController();
62+ searchController = controller;
63+ return new Promise((resolve, reject) => {
64+ controller.signal.addEventListener("abort", () => reject(abortError()), { once: true });
65+ timer = setTimer(async () => {
66+ timer = null;
67+ if (controller.signal.aborted) return;
68+ try {
69+ const response = await fetchImpl(
70+ `/api/catalog/places?q=${encodeURIComponent(normalized)}`,
71+ { signal: controller.signal, credentials: "omit", headers: { accept: "application/json" } },
72+ );
73+ const body = await responseJson(response);
74+ if (!isPlaceSearchResult(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
75+ if (!controller.signal.aborted) resolve(body.places);
76+ } catch (error) {
77+ if (!controller.signal.aborted) reject(error instanceof CatalogClientError
78+ ? error
79+ : new CatalogClientError("BACKEND_UNAVAILABLE"));
80+ } finally {
81+ if (searchController === controller) searchController = null;
82+ }
83+ }, SEARCH_DEBOUNCE_MS);
84+ });
85+ }
86+
87+ async function listServices(placeId, signal) {
88+ const response = await fetchImpl(
89+ `/api/catalog/places/${encodeURIComponent(placeId)}/services`,
90+ { signal, credentials: "omit", headers: { accept: "application/json" } },
91+ );
92+ const body = await responseJson(response);
93+ if (!isServiceOptionsResult(body, placeId)) throw new CatalogClientError("INVALID_SERVICE");
94+ return body.services;
95+ }
96+
97+ return { cancelSearch, searchPlaces, listServices };
98+}
modified packages/config-page/src/config-core.js +193 -27
@@ -20,6 +20,9 @@ export const LIMITS = {
2020 idUtf8Bytes: 64,
2121 labelUtf8Bytes: 96,
2222 favorites: 8,
23+ catalogQueryMinCharacters: 2,
24+ catalogQueryMaxCharacters: 100,
25+ catalogSearchResults: 20,
2326 };
2427
2528 const FAVORITE_FIELDS = [
@@ -30,6 +33,9 @@ const FAVORITE_FIELDS = [
3033 "stopLabel",
3134 "lineLabel",
3235 "destinationLabel",
36+ "lineMode",
37+ "lineColor",
38+ "lineTextColor",
3339 "sortOrder",
3440 ];
3541
@@ -43,6 +49,10 @@ function boundedString(value, maximum) {
4349 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
4450 }
4551
52+export function isLineColor(value) {
53+ return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value);
54+}
55+
4656 // --- Locale -----------------------------------------------------------------
4757 // French authored product copy is selected by any language tag beginning
4858 // "fr"; every other (or unknown) value falls back to English. User-authored
@@ -55,46 +65,100 @@ export function selectLocale(language) {
5565
5666 export const COPY = {
5767 en: {
58- pageTitle: "Lapin Futé — Settings",
59- keyTitle: "PRIM API key",
60- keyStatusConfigured: "A key is configured on your phone.",
61- keyStatusMissing: "No API key is configured.",
62- keyLabel: "API key",
63- keyPlaceholder: "Enter a new key",
68+ pageTitle: "Lapin Futé settings",
69+ intro: "Choose the departures you want at hand on your Pebble.",
70+ keyTitle: "PRIM access",
71+ keyStatusConfigured: "A key is configured on this phone.",
72+ keyStatusMissing: "No key is configured.",
73+ keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.",
74+ keyLink: "Create a PRIM token",
75+ keyLabel: "Replace with a new key",
76+ keyPlaceholder: "Paste a new key",
6477 keyShow: "Show",
6578 keyHide: "Hide",
66- keyRemove: "Remove key",
67- keyUndoRemove: "Keep key",
68- keyRemovePending: "The key will be removed when you save.",
69- keyErrorTooLong: "The key must be at most 512 bytes.",
79+ keyRemove: "Remove saved key",
80+ keyUndoRemove: "Keep saved key",
81+ keyRemovePending: "The saved key will be removed.",
82+ keyReplacementPending: "A replacement key is ready to save.",
83+ keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
7084 keyErrorNewline: "The key must not contain line breaks.",
71- favoritesTitle: "Favorites",
72- favoritesEmpty: "No favorites yet.",
85+ favoritesTitle: "Favorite departures",
86+ favoritesEmpty: "No favorites yet. Search for a stop to add one.",
87+ favoriteAddTitle: "Add a favorite",
88+ searchLabel: "Stop or station",
89+ searchPlaceholder: "Search by name",
90+ searchHint: "Enter at least 2 characters.",
91+ searchLoading: "Searching…",
92+ searchNoResults: "No matching stop or station.",
93+ backendUnavailable: "The service catalog is unavailable. Your edits are safe; try again.",
94+ invalidService: "This service is no longer available. Search again.",
95+ servicesLoading: "Loading lines and directions…",
96+ servicesLabel: "Line and direction",
97+ servicesEmpty: "No service is available for this stop.",
98+ previewRecorded: "Recorded example",
99+ minutesShort: "min",
100+ previewTitle: "Preview",
101+ favoriteNameLabel: "Favorite name (optional)",
102+ favoriteNamePlaceholder: "For example, Home",
103+ favoriteAdd: "Add favorite",
104+ favoriteLimit: "You have reached the 8-favorite limit.",
73105 favoriteMoveUp: "Move up",
74106 favoriteMoveDown: "Move down",
107+ favoriteRename: "Rename",
75108 favoriteRemove: "Remove",
76- save: "Save",
109+ renamePrompt: "Favorite name",
110+ save: "Save settings",
111+ aboutOpen: "About",
112+ aboutBack: "Back to settings",
113+ aboutTitle: "About Lapin Futé",
77114 },
78115 fr: {
79- pageTitle: "Lapin Futé — Réglages",
80- keyTitle: "Clé API PRIM",
81- keyStatusConfigured: "Une clé est configurée sur votre téléphone.",
82- keyStatusMissing: "Aucune clé API n’est configurée.",
83- keyLabel: "Clé API",
84- keyPlaceholder: "Saisir une nouvelle clé",
116+ pageTitle: "Réglages Lapin Futé",
117+ intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.",
118+ keyTitle: "Accès PRIM",
119+ keyStatusConfigured: "Une clé est configurée sur ce téléphone.",
120+ keyStatusMissing: "Aucune clé n’est configurée.",
121+ keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.",
122+ keyLink: "Créer un jeton PRIM",
123+ keyLabel: "Remplacer par une nouvelle clé",
124+ keyPlaceholder: "Coller une clé",
85125 keyShow: "Afficher",
86126 keyHide: "Masquer",
87- keyRemove: "Supprimer la clé",
88- keyUndoRemove: "Conserver la clé",
89- keyRemovePending: "La clé sera supprimée à l’enregistrement.",
90- keyErrorTooLong: "La clé doit contenir au maximum 512 octets.",
127+ keyRemove: "Supprimer la clé enregistrée",
128+ keyUndoRemove: "Conserver la clé enregistrée",
129+ keyRemovePending: "La clé enregistrée sera supprimée.",
130+ keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131+ keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
91132 keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
92- favoritesTitle: "Favoris",
93- favoritesEmpty: "Aucun favori pour le moment.",
133+ favoritesTitle: "Départs favoris",
134+ favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135+ favoriteAddTitle: "Ajouter un favori",
136+ searchLabel: "Arrêt ou gare",
137+ searchPlaceholder: "Rechercher par nom",
138+ searchHint: "Saisissez au moins 2 caractères.",
139+ searchLoading: "Recherche…",
140+ searchNoResults: "Aucun arrêt ni gare ne correspond.",
141+ backendUnavailable: "Le catalogue est indisponible. Vos modifications sont conservées ; réessayez.",
142+ invalidService: "Ce service n’est plus disponible. Relancez la recherche.",
143+ servicesLoading: "Chargement des lignes et directions…",
144+ servicesLabel: "Ligne et direction",
145+ servicesEmpty: "Aucun service n’est disponible pour cet arrêt.",
146+ previewRecorded: "Exemple enregistré",
147+ minutesShort: "min",
148+ previewTitle: "Aperçu",
149+ favoriteNameLabel: "Nom du favori (facultatif)",
150+ favoriteNamePlaceholder: "Par exemple, Maison",
151+ favoriteAdd: "Ajouter le favori",
152+ favoriteLimit: "Vous avez atteint la limite de 8 favoris.",
94153 favoriteMoveUp: "Monter",
95154 favoriteMoveDown: "Descendre",
155+ favoriteRename: "Renommer",
96156 favoriteRemove: "Supprimer",
97- save: "Enregistrer",
157+ renamePrompt: "Nom du favori",
158+ save: "Enregistrer les réglages",
159+ aboutOpen: "À propos",
160+ aboutBack: "Retour aux réglages",
161+ aboutTitle: "À propos de Lapin Futé",
98162 },
99163 };
100164
@@ -107,6 +171,10 @@ export function copyFor(language) {
107171 export function isFavoriteShape(value) {
108172 if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
109173 if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false;
174+ const hasLineMode = Object.hasOwn(value, "lineMode");
175+ const hasLineColor = Object.hasOwn(value, "lineColor");
176+ const hasLineTextColor = Object.hasOwn(value, "lineTextColor");
177+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
110178 return (
111179 value.schemaVersion === SCHEMA_VERSION &&
112180 boundedString(value.id, LIMITS.idUtf8Bytes) &&
@@ -115,10 +183,81 @@ export function isFavoriteShape(value) {
115183 boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) &&
116184 boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) &&
117185 boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) &&
186+ (!hasLineMode || (
187+ TRANSPORT_MODES.includes(value.lineMode) &&
188+ isLineColor(value.lineColor) &&
189+ isLineColor(value.lineTextColor)
190+ )) &&
118191 Number.isInteger(value.sortOrder)
119192 );
120193 }
121194
195+const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"];
196+const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"];
197+const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"];
198+
199+function exactFields(value, allowed, required) {
200+ return typeof value === "object" && value !== null && !Array.isArray(value)
201+ && Object.keys(value).every((field) => allowed.includes(field))
202+ && required.every((field) => Object.hasOwn(value, field));
203+}
204+
205+export function isPlaceSearchItem(value) {
206+ return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"])
207+ && boundedString(value.placeId, LIMITS.idUtf8Bytes)
208+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
209+ && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes))
210+ && TRANSPORT_MODES.includes(value.mode);
211+}
212+
213+export function isPlaceSearchResult(value) {
214+ return exactFields(value, ["schemaVersion", "places"], ["schemaVersion", "places"])
215+ && value.schemaVersion === SCHEMA_VERSION
216+ && Array.isArray(value.places)
217+ && value.places.length <= LIMITS.catalogSearchResults
218+ && value.places.every(isPlaceSearchItem);
219+}
220+
221+export function isServiceOption(value) {
222+ return exactFields(value, SERVICE_FIELDS, SERVICE_FIELDS)
223+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
224+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
225+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
226+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
227+ && TRANSPORT_MODES.includes(value.lineMode)
228+ && isLineColor(value.lineColor)
229+ && isLineColor(value.lineTextColor);
230+}
231+
232+export function isServiceOptionsResult(value, placeId) {
233+ return exactFields(value, ["schemaVersion", "placeId", "services"], ["schemaVersion", "placeId", "services"])
234+ && value.schemaVersion === SCHEMA_VERSION
235+ && value.placeId === placeId
236+ && Array.isArray(value.services)
237+ && value.services.every(isServiceOption);
238+}
239+
240+export function favoriteFromService(id, service, sortOrder, displayName = undefined) {
241+ if (!boundedString(id, LIMITS.idUtf8Bytes) || !isServiceOption(service)
242+ || !Number.isInteger(sortOrder) || sortOrder < 0) return null;
243+ const favorite = {
244+ schemaVersion: SCHEMA_VERSION,
245+ id,
246+ serviceId: service.serviceId,
247+ stopLabel: service.stopLabel,
248+ lineLabel: service.lineLabel,
249+ destinationLabel: service.destinationLabel,
250+ lineMode: service.lineMode,
251+ lineColor: service.lineColor,
252+ lineTextColor: service.lineTextColor,
253+ sortOrder,
254+ };
255+ if (displayName !== undefined && boundedString(displayName, LIMITS.labelUtf8Bytes)) {
256+ favorite.displayName = displayName;
257+ }
258+ return favorite;
259+}
260+
122261 function renumber(favorites) {
123262 return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder }));
124263 }
@@ -191,6 +330,28 @@ export function reduceConfigState(state, action) {
191330 return { ...state, keyDraft: { value: "", removeRequested: true } };
192331 case "key-remove-cancelled":
193332 return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } };
333+ case "favorite-add": {
334+ if (state.favorites.length >= LIMITS.favorites || !isFavoriteShape(action.favorite)
335+ || state.favorites.some((favorite) => favorite.id === action.favorite.id)) return state;
336+ return {
337+ ...state,
338+ favorites: renumber([...state.favorites, { ...action.favorite }]),
339+ };
340+ }
341+ case "favorite-rename": {
342+ const displayName = typeof action.displayName === "string" ? action.displayName.trim() : "";
343+ if (displayName.length > 0 && !boundedString(displayName, LIMITS.labelUtf8Bytes)) return state;
344+ let changed = false;
345+ const favorites = state.favorites.map((favorite) => {
346+ if (favorite.id !== action.id) return favorite;
347+ changed = true;
348+ const renamed = { ...favorite };
349+ if (displayName.length === 0) delete renamed.displayName;
350+ else renamed.displayName = displayName;
351+ return renamed;
352+ });
353+ return changed ? { ...state, favorites } : state;
354+ }
194355 case "favorite-remove": {
195356 if (!state.favorites.some((favorite) => favorite.id === action.id)) return state;
196357 return {
@@ -260,8 +421,13 @@ function stampFavorite(favorite, sortOrder) {
260421 stopLabel: favorite.stopLabel,
261422 lineLabel: favorite.lineLabel,
262423 destinationLabel: favorite.destinationLabel,
263- sortOrder,
264424 };
425+ if (Object.hasOwn(favorite, "lineMode")) {
426+ stamped.lineMode = favorite.lineMode;
427+ stamped.lineColor = favorite.lineColor;
428+ stamped.lineTextColor = favorite.lineTextColor;
429+ }
430+ stamped.sortOrder = sortOrder;
265431 if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName;
266432 return stamped;
267433 }
@@ -20,6 +20,9 @@ export const LIMITS = {
20 idUtf8Bytes: 64,20 idUtf8Bytes: 64,
21 labelUtf8Bytes: 96,21 labelUtf8Bytes: 96,
22 favorites: 8,22 favorites: 8,
23+ catalogQueryMinCharacters: 2,
24+ catalogQueryMaxCharacters: 100,
25+ catalogSearchResults: 20,
23 };26 };
24 27
25 const FAVORITE_FIELDS = [28 const FAVORITE_FIELDS = [
@@ -30,6 +33,9 @@ const FAVORITE_FIELDS = [
30 "stopLabel",33 "stopLabel",
31 "lineLabel",34 "lineLabel",
32 "destinationLabel",35 "destinationLabel",
36+ "lineMode",
37+ "lineColor",
38+ "lineTextColor",
33 "sortOrder",39 "sortOrder",
34 ];40 ];
35 41
@@ -43,6 +49,10 @@ function boundedString(value, maximum) {
43 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;49 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
44 }50 }
45 51
52+export function isLineColor(value) {
53+ return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value);
54+}
55+
46 // --- Locale -----------------------------------------------------------------56 // --- Locale -----------------------------------------------------------------
47 // French authored product copy is selected by any language tag beginning57 // French authored product copy is selected by any language tag beginning
48 // "fr"; every other (or unknown) value falls back to English. User-authored58 // "fr"; every other (or unknown) value falls back to English. User-authored
@@ -55,46 +65,100 @@ export function selectLocale(language) {
55 65
56 export const COPY = {66 export const COPY = {
57 en: {67 en: {
58- pageTitle: "Lapin Futé — Settings",68+ pageTitle: "Lapin Futé settings",
59- keyTitle: "PRIM API key",69+ intro: "Choose the departures you want at hand on your Pebble.",
60- keyStatusConfigured: "A key is configured on your phone.",70+ keyTitle: "PRIM access",
61- keyStatusMissing: "No API key is configured.",71+ keyStatusConfigured: "A key is configured on this phone.",
62- keyLabel: "API key",72+ keyStatusMissing: "No key is configured.",
63- keyPlaceholder: "Enter a new key",73+ keyExplanation: "Your personal key stays in plaintext on your phone. This page never receives the saved key.",
74+ keyLink: "Create a PRIM token",
75+ keyLabel: "Replace with a new key",
76+ keyPlaceholder: "Paste a new key",
64 keyShow: "Show",77 keyShow: "Show",
65 keyHide: "Hide",78 keyHide: "Hide",
66- keyRemove: "Remove key",79+ keyRemove: "Remove saved key",
67- keyUndoRemove: "Keep key",80+ keyUndoRemove: "Keep saved key",
68- keyRemovePending: "The key will be removed when you save.",81+ keyRemovePending: "The saved key will be removed.",
69- keyErrorTooLong: "The key must be at most 512 bytes.",82+ keyReplacementPending: "A replacement key is ready to save.",
83+ keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
70 keyErrorNewline: "The key must not contain line breaks.",84 keyErrorNewline: "The key must not contain line breaks.",
71- favoritesTitle: "Favorites",85+ favoritesTitle: "Favorite departures",
72- favoritesEmpty: "No favorites yet.",86+ favoritesEmpty: "No favorites yet. Search for a stop to add one.",
87+ favoriteAddTitle: "Add a favorite",
88+ searchLabel: "Stop or station",
89+ searchPlaceholder: "Search by name",
90+ searchHint: "Enter at least 2 characters.",
91+ searchLoading: "Searching…",
92+ searchNoResults: "No matching stop or station.",
93+ backendUnavailable: "The service catalog is unavailable. Your edits are safe; try again.",
94+ invalidService: "This service is no longer available. Search again.",
95+ servicesLoading: "Loading lines and directions…",
96+ servicesLabel: "Line and direction",
97+ servicesEmpty: "No service is available for this stop.",
98+ previewRecorded: "Recorded example",
99+ minutesShort: "min",
100+ previewTitle: "Preview",
101+ favoriteNameLabel: "Favorite name (optional)",
102+ favoriteNamePlaceholder: "For example, Home",
103+ favoriteAdd: "Add favorite",
104+ favoriteLimit: "You have reached the 8-favorite limit.",
73 favoriteMoveUp: "Move up",105 favoriteMoveUp: "Move up",
74 favoriteMoveDown: "Move down",106 favoriteMoveDown: "Move down",
107+ favoriteRename: "Rename",
75 favoriteRemove: "Remove",108 favoriteRemove: "Remove",
76- save: "Save",109+ renamePrompt: "Favorite name",
110+ save: "Save settings",
111+ aboutOpen: "About",
112+ aboutBack: "Back to settings",
113+ aboutTitle: "About Lapin Futé",
77 },114 },
78 fr: {115 fr: {
79- pageTitle: "Lapin Futé — Réglages",116+ pageTitle: "Réglages Lapin Futé",
80- keyTitle: "Clé API PRIM",117+ intro: "Choisissez les prochains départs à garder sous la main sur votre Pebble.",
81- keyStatusConfigured: "Une clé est configurée sur votre téléphone.",118+ keyTitle: "Accès PRIM",
82- keyStatusMissing: "Aucune clé API n’est configurée.",119+ keyStatusConfigured: "Une clé est configurée sur ce téléphone.",
83- keyLabel: "Clé API",120+ keyStatusMissing: "Aucune clé n’est configurée.",
84- keyPlaceholder: "Saisir une nouvelle clé",121+ keyExplanation: "Votre clé personnelle reste en clair sur votre téléphone. Cette page ne reçoit jamais la clé enregistrée.",
122+ keyLink: "Créer un jeton PRIM",
123+ keyLabel: "Remplacer par une nouvelle clé",
124+ keyPlaceholder: "Coller une clé",
85 keyShow: "Afficher",125 keyShow: "Afficher",
86 keyHide: "Masquer",126 keyHide: "Masquer",
87- keyRemove: "Supprimer la clé",127+ keyRemove: "Supprimer la clé enregistrée",
88- keyUndoRemove: "Conserver la clé",128+ keyUndoRemove: "Conserver la clé enregistrée",
89- keyRemovePending: "La clé sera supprimée à l’enregistrement.",129+ keyRemovePending: "La clé enregistrée sera supprimée.",
90- keyErrorTooLong: "La clé doit contenir au maximum 512 octets.",130+ keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131+ keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
91 keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",132 keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
92- favoritesTitle: "Favoris",133+ favoritesTitle: "Départs favoris",
93- favoritesEmpty: "Aucun favori pour le moment.",134+ favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135+ favoriteAddTitle: "Ajouter un favori",
136+ searchLabel: "Arrêt ou gare",
137+ searchPlaceholder: "Rechercher par nom",
138+ searchHint: "Saisissez au moins 2 caractères.",
139+ searchLoading: "Recherche…",
140+ searchNoResults: "Aucun arrêt ni gare ne correspond.",
141+ backendUnavailable: "Le catalogue est indisponible. Vos modifications sont conservées ; réessayez.",
142+ invalidService: "Ce service n’est plus disponible. Relancez la recherche.",
143+ servicesLoading: "Chargement des lignes et directions…",
144+ servicesLabel: "Ligne et direction",
145+ servicesEmpty: "Aucun service n’est disponible pour cet arrêt.",
146+ previewRecorded: "Exemple enregistré",
147+ minutesShort: "min",
148+ previewTitle: "Aperçu",
149+ favoriteNameLabel: "Nom du favori (facultatif)",
150+ favoriteNamePlaceholder: "Par exemple, Maison",
151+ favoriteAdd: "Ajouter le favori",
152+ favoriteLimit: "Vous avez atteint la limite de 8 favoris.",
94 favoriteMoveUp: "Monter",153 favoriteMoveUp: "Monter",
95 favoriteMoveDown: "Descendre",154 favoriteMoveDown: "Descendre",
155+ favoriteRename: "Renommer",
96 favoriteRemove: "Supprimer",156 favoriteRemove: "Supprimer",
97- save: "Enregistrer",157+ renamePrompt: "Nom du favori",
158+ save: "Enregistrer les réglages",
159+ aboutOpen: "À propos",
160+ aboutBack: "Retour aux réglages",
161+ aboutTitle: "À propos de Lapin Futé",
98 },162 },
99 };163 };
100 164
@@ -107,6 +171,10 @@ export function copyFor(language) {
107 export function isFavoriteShape(value) {171 export function isFavoriteShape(value) {
108 if (typeof value !== "object" || value === null || Array.isArray(value)) return false;172 if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
109 if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false;173 if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false;
174+ const hasLineMode = Object.hasOwn(value, "lineMode");
175+ const hasLineColor = Object.hasOwn(value, "lineColor");
176+ const hasLineTextColor = Object.hasOwn(value, "lineTextColor");
177+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
110 return (178 return (
111 value.schemaVersion === SCHEMA_VERSION &&179 value.schemaVersion === SCHEMA_VERSION &&
112 boundedString(value.id, LIMITS.idUtf8Bytes) &&180 boundedString(value.id, LIMITS.idUtf8Bytes) &&
@@ -115,10 +183,81 @@ export function isFavoriteShape(value) {
115 boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) &&183 boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) &&
116 boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) &&184 boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) &&
117 boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) &&185 boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) &&
186+ (!hasLineMode || (
187+ TRANSPORT_MODES.includes(value.lineMode) &&
188+ isLineColor(value.lineColor) &&
189+ isLineColor(value.lineTextColor)
190+ )) &&
118 Number.isInteger(value.sortOrder)191 Number.isInteger(value.sortOrder)
119 );192 );
120 }193 }
121 194
195+const PLACE_FIELDS = ["placeId", "stopLabel", "localityLabel", "mode"];
196+const SERVICE_FIELDS = ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"];
197+const TRANSPORT_MODES = ["BUS", "METRO", "TRAM", "RER", "TRANSILIEN"];
198+
199+function exactFields(value, allowed, required) {
200+ return typeof value === "object" && value !== null && !Array.isArray(value)
201+ && Object.keys(value).every((field) => allowed.includes(field))
202+ && required.every((field) => Object.hasOwn(value, field));
203+}
204+
205+export function isPlaceSearchItem(value) {
206+ return exactFields(value, PLACE_FIELDS, ["placeId", "stopLabel", "mode"])
207+ && boundedString(value.placeId, LIMITS.idUtf8Bytes)
208+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
209+ && (value.localityLabel === undefined || boundedString(value.localityLabel, LIMITS.labelUtf8Bytes))
210+ && TRANSPORT_MODES.includes(value.mode);
211+}
212+
213+export function isPlaceSearchResult(value) {
214+ return exactFields(value, ["schemaVersion", "places"], ["schemaVersion", "places"])
215+ && value.schemaVersion === SCHEMA_VERSION
216+ && Array.isArray(value.places)
217+ && value.places.length <= LIMITS.catalogSearchResults
218+ && value.places.every(isPlaceSearchItem);
219+}
220+
221+export function isServiceOption(value) {
222+ return exactFields(value, SERVICE_FIELDS, SERVICE_FIELDS)
223+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
224+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
225+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
226+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
227+ && TRANSPORT_MODES.includes(value.lineMode)
228+ && isLineColor(value.lineColor)
229+ && isLineColor(value.lineTextColor);
230+}
231+
232+export function isServiceOptionsResult(value, placeId) {
233+ return exactFields(value, ["schemaVersion", "placeId", "services"], ["schemaVersion", "placeId", "services"])
234+ && value.schemaVersion === SCHEMA_VERSION
235+ && value.placeId === placeId
236+ && Array.isArray(value.services)
237+ && value.services.every(isServiceOption);
238+}
239+
240+export function favoriteFromService(id, service, sortOrder, displayName = undefined) {
241+ if (!boundedString(id, LIMITS.idUtf8Bytes) || !isServiceOption(service)
242+ || !Number.isInteger(sortOrder) || sortOrder < 0) return null;
243+ const favorite = {
244+ schemaVersion: SCHEMA_VERSION,
245+ id,
246+ serviceId: service.serviceId,
247+ stopLabel: service.stopLabel,
248+ lineLabel: service.lineLabel,
249+ destinationLabel: service.destinationLabel,
250+ lineMode: service.lineMode,
251+ lineColor: service.lineColor,
252+ lineTextColor: service.lineTextColor,
253+ sortOrder,
254+ };
255+ if (displayName !== undefined && boundedString(displayName, LIMITS.labelUtf8Bytes)) {
256+ favorite.displayName = displayName;
257+ }
258+ return favorite;
259+}
260+
122 function renumber(favorites) {261 function renumber(favorites) {
123 return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder }));262 return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder }));
124 }263 }
@@ -191,6 +330,28 @@ export function reduceConfigState(state, action) {
191 return { ...state, keyDraft: { value: "", removeRequested: true } };330 return { ...state, keyDraft: { value: "", removeRequested: true } };
192 case "key-remove-cancelled":331 case "key-remove-cancelled":
193 return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } };332 return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } };
333+ case "favorite-add": {
334+ if (state.favorites.length >= LIMITS.favorites || !isFavoriteShape(action.favorite)
335+ || state.favorites.some((favorite) => favorite.id === action.favorite.id)) return state;
336+ return {
337+ ...state,
338+ favorites: renumber([...state.favorites, { ...action.favorite }]),
339+ };
340+ }
341+ case "favorite-rename": {
342+ const displayName = typeof action.displayName === "string" ? action.displayName.trim() : "";
343+ if (displayName.length > 0 && !boundedString(displayName, LIMITS.labelUtf8Bytes)) return state;
344+ let changed = false;
345+ const favorites = state.favorites.map((favorite) => {
346+ if (favorite.id !== action.id) return favorite;
347+ changed = true;
348+ const renamed = { ...favorite };
349+ if (displayName.length === 0) delete renamed.displayName;
350+ else renamed.displayName = displayName;
351+ return renamed;
352+ });
353+ return changed ? { ...state, favorites } : state;
354+ }
194 case "favorite-remove": {355 case "favorite-remove": {
195 if (!state.favorites.some((favorite) => favorite.id === action.id)) return state;356 if (!state.favorites.some((favorite) => favorite.id === action.id)) return state;
196 return {357 return {
@@ -260,8 +421,13 @@ function stampFavorite(favorite, sortOrder) {
260 stopLabel: favorite.stopLabel,421 stopLabel: favorite.stopLabel,
261 lineLabel: favorite.lineLabel,422 lineLabel: favorite.lineLabel,
262 destinationLabel: favorite.destinationLabel,423 destinationLabel: favorite.destinationLabel,
263- sortOrder,
264 };424 };
425+ if (Object.hasOwn(favorite, "lineMode")) {
426+ stamped.lineMode = favorite.lineMode;
427+ stamped.lineColor = favorite.lineColor;
428+ stamped.lineTextColor = favorite.lineTextColor;
429+ }
430+ stamped.sortOrder = sortOrder;
265 if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName;431 if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName;
266 return stamped;432 return stamped;
267 }433 }
modified packages/config-page/src/config-page.js +350 -128
@@ -1,177 +1,399 @@
1-// DOM wiring for the Lapin Futé configuration page. All state transitions and
2-// serialization live in config-core.js; this layer only renders and dispatches.
3-//
4-// Secret boundary: the personal PRIM key exists only in the masked input's
5-// value. The page holds no storage of any kind, performs no network requests,
6-// and never logs; its single navigation is the one-time close fragment, which
7-// the mobile app intercepts before any HTTP request could exist.
8-//
9-// The opening fragment carries only non-secret fields: hasKey, the watch
10-// language, and the current favorites list. The stored key itself is never
11-// present in the fragment, the DOM, or this source.
12-
131 import {
2+ LIMITS,
3+ apiKeyError,
144 copyFor,
155 createCloseSession,
6+ favoriteFromService,
167 initialConfigState,
178 parseConfigFragment,
189 planConfigResult,
1910 reduceConfigState,
11+ utf8Bytes,
2012 } from "./config-core.js";
13+import { CatalogClientError, createCatalogClient } from "./catalog-client.js";
14+import { RECORDED_PREVIEW } from "./preview-fixture.js";
15+import { lineBadgeAssetUrl } from "./line-badge-assets.js";
2116
2217 function byId(id) {
2318 return document.getElementById(id);
2419 }
2520
26-const elements = {
27- pageTitle: byId("page-title"),
28- keyTitle: byId("key-title"),
29- keyStatus: byId("key-status"),
30- keyLabel: byId("key-label"),
31- keyInput: byId("key-input"),
32- keyToggle: byId("key-toggle"),
33- keyError: byId("key-error"),
34- keyPending: byId("key-pending"),
35- keyRemove: byId("key-remove"),
36- favoritesTitle: byId("favorites-title"),
37- favoritesEmpty: byId("favorites-empty"),
38- favoritesList: byId("favorites-list"),
39- save: byId("save"),
40-};
41-
42-const query = parseConfigFragment(window.location.hash);
43-const copy = copyFor(query.language);
44-let state = initialConfigState(query);
45-let revealed = false;
46-const session = createCloseSession();
47-
48-function setText(element, text) {
49- if (element.textContent !== text) {
50- element.textContent = text;
21+const elements = Object.fromEntries([
22+ "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label",
23+ "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title",
24+ "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint",
25+ "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview",
26+ "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures",
27+ "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view",
28+ "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr",
29+].map((id) => [id.replaceAll("-", "_"), byId(id)]));
30+
31+const opening = parseConfigFragment(window.location.hash);
32+const copy = copyFor(opening.language);
33+const catalog = createCatalogClient();
34+const closeSession = createCloseSession();
35+let state = initialConfigState(opening);
36+let places = [];
37+let services = [];
38+let selectedPlace = null;
39+let selectedService = null;
40+let serviceController = null;
41+let searchGeneration = 0;
42+let idSequence = 0;
43+let keyRevealed = false;
44+
45+let aboutReturnFocus = elements.about_open;
46+function setText(element, value) {
47+ if (element.textContent !== value) element.textContent = value;
48+}
49+
50+const NEUTRAL_LINE_BACKGROUND = "#52616f";
51+const NEUTRAL_LINE_TEXT = "#ffffff";
52+
53+function fallbackLineBadge(service) {
54+ const namespace = "http://www.w3.org/2000/svg";
55+ const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12));
56+ const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND;
57+ const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT;
58+ const svg = document.createElementNS(namespace, "svg");
59+ svg.setAttribute("viewBox", `0 0 ${width} 40`);
60+ svg.setAttribute("role", "img");
61+ svg.setAttribute("aria-label", service.lineLabel);
62+ svg.setAttribute("focusable", "false");
63+ const title = document.createElementNS(namespace, "title");
64+ title.textContent = service.lineLabel;
65+ const background = document.createElementNS(namespace, "rect");
66+ background.setAttribute("width", String(width));
67+ background.setAttribute("height", "40");
68+ background.setAttribute("rx", "6");
69+ background.setAttribute("fill", backgroundColor);
70+ const label = document.createElementNS(namespace, "text");
71+ label.setAttribute("x", String(width / 2));
72+ label.setAttribute("y", "21");
73+ label.setAttribute("text-anchor", "middle");
74+ label.setAttribute("dominant-baseline", "middle");
75+ label.setAttribute("fill", textColor);
76+ label.setAttribute("font-family", "system-ui, sans-serif");
77+ label.setAttribute("font-size", "18");
78+ label.setAttribute("font-weight", "800");
79+ label.textContent = service.lineLabel;
80+ svg.append(title, background, label);
81+ return svg;
82+}
83+
84+function renderLineBadge(container, service) {
85+ container.className = "line-badge";
86+ const assetUrl = service.lineMode === undefined
87+ ? undefined
88+ : lineBadgeAssetUrl(service.lineMode, service.lineLabel);
89+ if (assetUrl === undefined) {
90+ container.replaceChildren(fallbackLineBadge(service));
91+ return;
5192 }
93+ const image = document.createElement("img");
94+ image.alt = service.lineLabel;
95+ image.src = assetUrl;
96+ image.addEventListener("error", () => {
97+ if (image.parentNode === container) container.replaceChildren(fallbackLineBadge(service));
98+ }, { once: true });
99+ container.replaceChildren(image);
52100 }
53101
54102 function dispatch(action) {
55- const next = reduceConfigState(state, action);
56- if (next !== state) {
57- state = next;
58- }
59- render();
103+ state = reduceConfigState(state, action);
104+ renderKey();
105+ renderFavorites();
60106 }
61107
62-function applyStaticCopy() {
108+function applyCopy() {
63109 document.title = copy.pageTitle;
64- document.documentElement.lang = query.locale;
65- setText(elements.pageTitle, copy.pageTitle);
66- setText(elements.keyTitle, copy.keyTitle);
67- setText(elements.keyLabel, copy.keyLabel);
68- elements.keyInput.placeholder = copy.keyPlaceholder;
69- setText(elements.keyToggle, copy.keyShow);
70- setText(elements.favoritesTitle, copy.favoritesTitle);
71- setText(elements.favoritesEmpty, copy.favoritesEmpty);
72- setText(elements.save, copy.save);
110+ document.documentElement.lang = opening.locale;
111+ for (const [key, element] of [
112+ ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],
113+ ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link],
114+ ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title],
115+ ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title],
116+ ["searchLabel", elements.search_label], ["searchHint", elements.search_hint],
117+ ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title],
118+ ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add],
119+ ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back],
120+ ["aboutTitle", elements.about_title],
121+ ]) setText(element, copy[key]);
122+ elements.key_input.placeholder = copy.keyPlaceholder;
123+ elements.place_search.placeholder = copy.searchPlaceholder;
124+ elements.favorite_name.placeholder = copy.favoriteNamePlaceholder;
125+ elements.about_en.hidden = opening.locale !== "en";
126+ elements.about_fr.hidden = opening.locale !== "fr";
73127 }
74128
75-function render() {
76- renderKeySection();
77- renderFavorites();
129+function renderKey() {
130+ setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
131+ const removing = state.keyDraft.removeRequested;
132+ const replacing = state.keyDraft.value.length > 0;
133+ elements.key_pending.hidden = !removing && !replacing;
134+ if (removing) setText(elements.key_pending, copy.keyRemovePending);
135+ else if (replacing) setText(elements.key_pending, copy.keyReplacementPending);
136+ elements.key_remove.hidden = !state.hasKey;
137+ setText(elements.key_remove, removing ? copy.keyUndoRemove : copy.keyRemove);
138+ setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow);
78139 }
79140
80-function renderKeySection() {
81- setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
82- const pending = state.keyDraft.removeRequested;
83- elements.keyPending.hidden = !pending;
84- if (pending) {
85- setText(elements.keyPending, copy.keyRemovePending);
86- }
87- elements.keyRemove.hidden = !state.hasKey;
88- setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove);
141+function actionButton(label, disabled, action, className = "quiet") {
142+ const button = document.createElement("button");
143+ button.type = "button";
144+ button.className = className;
145+ button.textContent = label;
146+ button.disabled = disabled;
147+ button.addEventListener("click", action);
148+ return button;
89149 }
90150
91151 function renderFavorites() {
92- elements.favoritesList.replaceChildren();
93- elements.favoritesEmpty.hidden = state.favorites.length > 0;
152+ elements.favorites_list.replaceChildren();
153+ elements.favorites_empty.hidden = state.favorites.length > 0;
154+ elements.add_section.hidden = state.favorites.length >= LIMITS.favorites;
155+ if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit);
156+ else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, "");
157+
94158 state.favorites.forEach((favorite, index) => {
95- elements.favoritesList.append(favoriteItem(favorite, index));
159+ const item = document.createElement("li");
160+ item.className = "favorite";
161+ const route = document.createElement("span");
162+ renderLineBadge(route, favorite);
163+ const labels = document.createElement("div");
164+ labels.className = "favorite-labels";
165+ const stop = document.createElement("strong");
166+ stop.textContent = favorite.stopLabel;
167+ const destination = document.createElement("span");
168+ destination.textContent = favorite.destinationLabel;
169+ labels.append(stop, destination);
170+
171+ const renameLabel = document.createElement("label");
172+ renameLabel.className = "visually-hidden";
173+ const renameId = `favorite-name-${index}`;
174+ renameLabel.htmlFor = renameId;
175+ renameLabel.textContent = copy.favoriteRename;
176+ const rename = document.createElement("input");
177+ rename.id = renameId;
178+ rename.type = "text";
179+ rename.autocomplete = "off";
180+ rename.maxLength = LIMITS.labelUtf8Bytes;
181+ rename.value = favorite.displayName ?? "";
182+ rename.placeholder = copy.favoriteNamePlaceholder;
183+ rename.addEventListener("change", () => {
184+ if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) {
185+ dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value });
186+ } else {
187+ rename.value = favorite.displayName ?? "";
188+ }
189+ });
190+
191+
192+ const actions = document.createElement("div");
193+ actions.className = "favorite-actions";
194+ actions.append(
195+ actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })),
196+ actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })),
197+ actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"),
198+ );
199+ item.append(route, labels, renameLabel, rename, actions);
200+ elements.favorites_list.append(item);
96201 });
97202 }
98203
99-function favoriteItem(favorite, index) {
100- const item = document.createElement("li");
101- item.className = "favorite";
102-
103- const heading = document.createElement("p");
104- heading.className = "labels";
105- heading.textContent = favorite.displayName ?? favorite.stopLabel;
106-
107- const detail = document.createElement("p");
108- detail.className = "sub";
109- // Authored and transport labels cross every locale verbatim.
110- detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · ");
111-
112- const actions = document.createElement("div");
113- actions.className = "favorite-actions";
114- actions.append(
115- listButton(copy.favoriteMoveUp, index === 0, () =>
116- dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }),
117- ),
118- listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () =>
119- dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }),
120- ),
121- listButton(copy.favoriteRemove, false, () =>
122- dispatch({ type: "favorite-remove", id: favorite.id }),
123- ),
124- );
204+function clearServiceSelection() {
205+ if (serviceController !== null) serviceController.abort();
206+ serviceController = null;
207+ services = [];
208+ selectedPlace = null;
209+ selectedService = null;
210+ elements.service_step.hidden = true;
211+ elements.preview.hidden = true;
212+ elements.service_select.replaceChildren();
213+}
125214
126- item.append(heading, detail, actions);
127- return item;
215+function renderPlaces() {
216+ elements.place_results.replaceChildren();
217+ for (const place of places) {
218+ const item = document.createElement("li");
219+ const label = place.localityLabel
220+ ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}`
221+ : `${place.stopLabel} · ${place.mode}`;
222+ item.append(actionButton(label, false, () => selectPlace(place), "choice"));
223+ elements.place_results.append(item);
224+ }
128225 }
129226
130-function listButton(label, disabled, onClick) {
131- const button = document.createElement("button");
132- button.type = "button";
133- button.textContent = label;
134- button.disabled = disabled;
135- button.addEventListener("click", onClick);
136- return button;
227+async function selectPlace(place) {
228+ clearServiceSelection();
229+ selectedPlace = place;
230+ serviceController = new AbortController();
231+ const controller = serviceController;
232+ setText(elements.catalog_status, copy.servicesLoading);
233+ try {
234+ services = await catalog.listServices(place.placeId, controller.signal);
235+ if (controller.signal.aborted) return;
236+ serviceController = null;
237+ if (services.length === 0) {
238+ setText(elements.catalog_status, copy.servicesEmpty);
239+ return;
240+ }
241+ elements.service_select.replaceChildren();
242+ const prompt = document.createElement("option");
243+ prompt.value = "";
244+ prompt.textContent = copy.servicesLabel;
245+ elements.service_select.append(prompt);
246+ services.forEach((service, index) => {
247+ const option = document.createElement("option");
248+ option.value = String(index);
249+ option.textContent = `${service.lineLabel} · ${service.destinationLabel}`;
250+ elements.service_select.append(option);
251+ });
252+ elements.service_step.hidden = false;
253+ setText(elements.catalog_status, "");
254+ elements.service_select.focus();
255+ } catch (error) {
256+ if (controller.signal.aborted) return;
257+ serviceController = null;
258+ setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE"
259+ ? copy.invalidService
260+ : copy.backendUnavailable);
261+ }
262+}
263+
264+function renderPreview() {
265+ elements.preview.hidden = selectedService === null;
266+ if (selectedService === null) return;
267+ renderLineBadge(elements.preview_line, selectedService);
268+ setText(elements.preview_stop, selectedService.stopLabel);
269+ setText(elements.preview_destination, selectedService.destinationLabel);
270+ setText(
271+ elements.preview_departures,
272+ `${copy.previewRecorded}: ${RECORDED_PREVIEW.map((entry) => `${entry.minutes} ${copy.minutesShort}`).join(" · ")}`,
273+ );
274+}
275+
276+function nextFavoriteId() {
277+ let candidate;
278+ do {
279+ idSequence += 1;
280+ candidate = `cfg-${Date.now().toString(36)}-${idSequence.toString(36)}`;
281+ } while (state.favorites.some((favorite) => favorite.id === candidate));
282+ return candidate;
137283 }
138284
139-elements.keyInput.addEventListener("input", () => {
140- elements.keyError.hidden = true;
141- dispatch({ type: "key-draft", value: elements.keyInput.value });
285+elements.key_input.addEventListener("input", () => {
286+ elements.key_error.hidden = true;
287+ dispatch({ type: "key-draft", value: elements.key_input.value });
142288 });
143289
144-elements.keyToggle.addEventListener("click", () => {
145- revealed = !revealed;
146- elements.keyInput.type = revealed ? "text" : "password";
147- setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow);
148- elements.keyToggle.setAttribute("aria-pressed", String(revealed));
149- elements.keyInput.focus();
290+elements.key_toggle.addEventListener("click", () => {
291+ keyRevealed = !keyRevealed;
292+ elements.key_input.type = keyRevealed ? "text" : "password";
293+ elements.key_toggle.setAttribute("aria-pressed", String(keyRevealed));
294+ renderKey();
295+ elements.key_input.focus();
150296 });
151297
152-elements.keyRemove.addEventListener("click", () => {
153- dispatch(
154- state.keyDraft.removeRequested
155- ? { type: "key-remove-cancelled" }
156- : { type: "key-remove-requested" },
157- );
298+elements.key_remove.addEventListener("click", () => {
299+ elements.key_input.value = "";
300+ dispatch(state.keyDraft.removeRequested
301+ ? { type: "key-remove-cancelled" }
302+ : { type: "key-remove-requested" });
158303 });
159304
160-elements.save.addEventListener("click", () => {
161- const outcome = planConfigResult(state);
162- if (!outcome.ok) {
163- elements.keyError.textContent = copy[outcome.error];
164- elements.keyError.hidden = false;
165- elements.keyInput.focus();
305+elements.place_search.addEventListener("input", async () => {
306+ const generation = ++searchGeneration;
307+ clearServiceSelection();
308+ places = [];
309+ renderPlaces();
310+ const query = elements.place_search.value.trim();
311+ const length = Array.from(query).length;
312+ if (length < LIMITS.catalogQueryMinCharacters) {
313+ catalog.cancelSearch();
314+ setText(elements.catalog_status, "");
166315 return;
167316 }
168- const fragment = session.close(outcome.payload);
169- if (fragment === null) {
317+ setText(elements.catalog_status, copy.searchLoading);
318+ try {
319+ const result = await catalog.searchPlaces(query);
320+ if (generation !== searchGeneration) return;
321+ places = result;
322+ renderPlaces();
323+ setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : "");
324+ } catch (error) {
325+ if (error?.name === "AbortError" || generation !== searchGeneration) return;
326+ setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY"
327+ ? copy.searchHint
328+ : copy.backendUnavailable);
329+ }
330+});
331+
332+elements.service_select.addEventListener("change", () => {
333+ const index = Number(elements.service_select.value);
334+ selectedService = Number.isInteger(index) && index >= 0 ? services[index] ?? null : null;
335+ renderPreview();
336+});
337+
338+elements.favorite_add.addEventListener("click", () => {
339+ if (selectedPlace === null || selectedService === null || !services.includes(selectedService)) {
340+ setText(elements.catalog_status, copy.invalidService);
170341 return;
171342 }
343+ const name = elements.favorite_name.value.trim();
344+ if (name.length > 0 && utf8Bytes(name) > LIMITS.labelUtf8Bytes) {
345+ setText(elements.catalog_status, copy.invalidService);
346+ return;
347+ }
348+ const favorite = favoriteFromService(nextFavoriteId(), selectedService, state.favorites.length, name || undefined);
349+ if (favorite === null) {
350+ setText(elements.catalog_status, copy.invalidService);
351+ return;
352+ }
353+ dispatch({ type: "favorite-add", favorite });
354+ elements.favorite_name.value = "";
355+ elements.place_search.value = "";
356+ places = [];
357+ renderPlaces();
358+ clearServiceSelection();
359+ setText(elements.catalog_status, "");
360+});
361+
362+elements.about_open.addEventListener("click", () => {
363+ aboutReturnFocus = document.activeElement instanceof HTMLElement
364+ ? document.activeElement
365+ : elements.about_open;
366+ elements.config_view.hidden = true;
367+ elements.config_footer.hidden = true;
368+ elements.about_view.hidden = false;
369+ elements.about_back.focus();
370+});
371+
372+elements.about_back.addEventListener("click", () => {
373+ elements.about_view.hidden = true;
374+ elements.config_view.hidden = false;
375+ elements.config_footer.hidden = false;
376+ aboutReturnFocus.focus();
377+});
378+
379+elements.save.addEventListener("click", () => {
380+ const keyError = apiKeyError(state.keyDraft.value);
381+ if (keyError !== null) {
382+ setText(elements.key_error, copy[keyError]);
383+ elements.key_error.hidden = false;
384+ elements.key_input.focus();
385+ return;
386+ }
387+ const outcome = planConfigResult(state);
388+ if (!outcome.ok) return;
389+ const closeUrl = closeSession.close(outcome.payload);
390+ if (closeUrl === null) return;
172391 elements.save.disabled = true;
173- window.location.assign(fragment);
392+ window.location.assign(closeUrl);
174393 });
175394
176-applyStaticCopy();
177-render();
395+applyCopy();
396+renderKey();
397+renderFavorites();
398+setText(elements.catalog_status, "");
399+document.documentElement.classList.add("ready");
@@ -1,177 +1,399 @@
1-// DOM wiring for the Lapin Futé configuration page. All state transitions and
2-// serialization live in config-core.js; this layer only renders and dispatches.
3-//
4-// Secret boundary: the personal PRIM key exists only in the masked input's
5-// value. The page holds no storage of any kind, performs no network requests,
6-// and never logs; its single navigation is the one-time close fragment, which
7-// the mobile app intercepts before any HTTP request could exist.
8-//
9-// The opening fragment carries only non-secret fields: hasKey, the watch
10-// language, and the current favorites list. The stored key itself is never
11-// present in the fragment, the DOM, or this source.
12-
13 import {1 import {
2+ LIMITS,
3+ apiKeyError,
14 copyFor,4 copyFor,
15 createCloseSession,5 createCloseSession,
6+ favoriteFromService,
16 initialConfigState,7 initialConfigState,
17 parseConfigFragment,8 parseConfigFragment,
18 planConfigResult,9 planConfigResult,
19 reduceConfigState,10 reduceConfigState,
11+ utf8Bytes,
20 } from "./config-core.js";12 } from "./config-core.js";
13+import { CatalogClientError, createCatalogClient } from "./catalog-client.js";
14+import { RECORDED_PREVIEW } from "./preview-fixture.js";
15+import { lineBadgeAssetUrl } from "./line-badge-assets.js";
21 16
22 function byId(id) {17 function byId(id) {
23 return document.getElementById(id);18 return document.getElementById(id);
24 }19 }
25 20
26-const elements = {21+const elements = Object.fromEntries([
27- pageTitle: byId("page-title"),22+ "page-title", "intro", "key-title", "key-status", "key-explanation", "key-link", "key-label",
28- keyTitle: byId("key-title"),23+ "key-input", "key-toggle", "key-error", "key-pending", "key-remove", "favorites-title",
29- keyStatus: byId("key-status"),24+ "favorites-empty", "favorites-list", "add-title", "search-label", "place-search", "search-hint",
30- keyLabel: byId("key-label"),25+ "catalog-status", "place-results", "service-step", "services-label", "service-select", "preview",
31- keyInput: byId("key-input"),26+ "preview-title", "preview-line", "preview-stop", "preview-destination", "preview-departures",
32- keyToggle: byId("key-toggle"),27+ "favorite-name-label", "favorite-name", "favorite-add", "add-section", "save", "config-view",
33- keyError: byId("key-error"),28+ "config-footer", "about-open", "about-view", "about-back", "about-title", "about-en", "about-fr",
34- keyPending: byId("key-pending"),29+].map((id) => [id.replaceAll("-", "_"), byId(id)]));
35- keyRemove: byId("key-remove"),30+
36- favoritesTitle: byId("favorites-title"),31+const opening = parseConfigFragment(window.location.hash);
37- favoritesEmpty: byId("favorites-empty"),32+const copy = copyFor(opening.language);
38- favoritesList: byId("favorites-list"),33+const catalog = createCatalogClient();
39- save: byId("save"),34+const closeSession = createCloseSession();
40-};35+let state = initialConfigState(opening);
41-36+let places = [];
42-const query = parseConfigFragment(window.location.hash);37+let services = [];
43-const copy = copyFor(query.language);38+let selectedPlace = null;
44-let state = initialConfigState(query);39+let selectedService = null;
45-let revealed = false;40+let serviceController = null;
46-const session = createCloseSession();41+let searchGeneration = 0;
47-42+let idSequence = 0;
48-function setText(element, text) {43+let keyRevealed = false;
49- if (element.textContent !== text) {44+
50- element.textContent = text;45+let aboutReturnFocus = elements.about_open;
46+function setText(element, value) {
47+ if (element.textContent !== value) element.textContent = value;
48+}
49+
50+const NEUTRAL_LINE_BACKGROUND = "#52616f";
51+const NEUTRAL_LINE_TEXT = "#ffffff";
52+
53+function fallbackLineBadge(service) {
54+ const namespace = "http://www.w3.org/2000/svg";
55+ const width = Math.max(40, 18 + (Array.from(service.lineLabel).length * 12));
56+ const backgroundColor = service.lineColor ?? NEUTRAL_LINE_BACKGROUND;
57+ const textColor = service.lineTextColor ?? NEUTRAL_LINE_TEXT;
58+ const svg = document.createElementNS(namespace, "svg");
59+ svg.setAttribute("viewBox", `0 0 ${width} 40`);
60+ svg.setAttribute("role", "img");
61+ svg.setAttribute("aria-label", service.lineLabel);
62+ svg.setAttribute("focusable", "false");
63+ const title = document.createElementNS(namespace, "title");
64+ title.textContent = service.lineLabel;
65+ const background = document.createElementNS(namespace, "rect");
66+ background.setAttribute("width", String(width));
67+ background.setAttribute("height", "40");
68+ background.setAttribute("rx", "6");
69+ background.setAttribute("fill", backgroundColor);
70+ const label = document.createElementNS(namespace, "text");
71+ label.setAttribute("x", String(width / 2));
72+ label.setAttribute("y", "21");
73+ label.setAttribute("text-anchor", "middle");
74+ label.setAttribute("dominant-baseline", "middle");
75+ label.setAttribute("fill", textColor);
76+ label.setAttribute("font-family", "system-ui, sans-serif");
77+ label.setAttribute("font-size", "18");
78+ label.setAttribute("font-weight", "800");
79+ label.textContent = service.lineLabel;
80+ svg.append(title, background, label);
81+ return svg;
82+}
83+
84+function renderLineBadge(container, service) {
85+ container.className = "line-badge";
86+ const assetUrl = service.lineMode === undefined
87+ ? undefined
88+ : lineBadgeAssetUrl(service.lineMode, service.lineLabel);
89+ if (assetUrl === undefined) {
90+ container.replaceChildren(fallbackLineBadge(service));
91+ return;
51 }92 }
93+ const image = document.createElement("img");
94+ image.alt = service.lineLabel;
95+ image.src = assetUrl;
96+ image.addEventListener("error", () => {
97+ if (image.parentNode === container) container.replaceChildren(fallbackLineBadge(service));
98+ }, { once: true });
99+ container.replaceChildren(image);
52 }100 }
53 101
54 function dispatch(action) {102 function dispatch(action) {
55- const next = reduceConfigState(state, action);103+ state = reduceConfigState(state, action);
56- if (next !== state) {104+ renderKey();
57- state = next;105+ renderFavorites();
58- }
59- render();
60 }106 }
61 107
62-function applyStaticCopy() {108+function applyCopy() {
63 document.title = copy.pageTitle;109 document.title = copy.pageTitle;
64- document.documentElement.lang = query.locale;110+ document.documentElement.lang = opening.locale;
65- setText(elements.pageTitle, copy.pageTitle);111+ for (const [key, element] of [
66- setText(elements.keyTitle, copy.keyTitle);112+ ["pageTitle", elements.page_title], ["intro", elements.intro], ["keyTitle", elements.key_title],
67- setText(elements.keyLabel, copy.keyLabel);113+ ["keyExplanation", elements.key_explanation], ["keyLink", elements.key_link],
68- elements.keyInput.placeholder = copy.keyPlaceholder;114+ ["keyLabel", elements.key_label], ["favoritesTitle", elements.favorites_title],
69- setText(elements.keyToggle, copy.keyShow);115+ ["favoritesEmpty", elements.favorites_empty], ["favoriteAddTitle", elements.add_title],
70- setText(elements.favoritesTitle, copy.favoritesTitle);116+ ["searchLabel", elements.search_label], ["searchHint", elements.search_hint],
71- setText(elements.favoritesEmpty, copy.favoritesEmpty);117+ ["servicesLabel", elements.services_label], ["previewTitle", elements.preview_title],
72- setText(elements.save, copy.save);118+ ["favoriteNameLabel", elements.favorite_name_label], ["favoriteAdd", elements.favorite_add],
119+ ["save", elements.save], ["aboutOpen", elements.about_open], ["aboutBack", elements.about_back],
120+ ["aboutTitle", elements.about_title],
121+ ]) setText(element, copy[key]);
122+ elements.key_input.placeholder = copy.keyPlaceholder;
123+ elements.place_search.placeholder = copy.searchPlaceholder;
124+ elements.favorite_name.placeholder = copy.favoriteNamePlaceholder;
125+ elements.about_en.hidden = opening.locale !== "en";
126+ elements.about_fr.hidden = opening.locale !== "fr";
73 }127 }
74 128
75-function render() {129+function renderKey() {
76- renderKeySection();130+ setText(elements.key_status, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
77- renderFavorites();131+ const removing = state.keyDraft.removeRequested;
132+ const replacing = state.keyDraft.value.length > 0;
133+ elements.key_pending.hidden = !removing && !replacing;
134+ if (removing) setText(elements.key_pending, copy.keyRemovePending);
135+ else if (replacing) setText(elements.key_pending, copy.keyReplacementPending);
136+ elements.key_remove.hidden = !state.hasKey;
137+ setText(elements.key_remove, removing ? copy.keyUndoRemove : copy.keyRemove);
138+ setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow);
78 }139 }
79 140
80-function renderKeySection() {141+function actionButton(label, disabled, action, className = "quiet") {
81- setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);142+ const button = document.createElement("button");
82- const pending = state.keyDraft.removeRequested;143+ button.type = "button";
83- elements.keyPending.hidden = !pending;144+ button.className = className;
84- if (pending) {145+ button.textContent = label;
85- setText(elements.keyPending, copy.keyRemovePending);146+ button.disabled = disabled;
86- }147+ button.addEventListener("click", action);
87- elements.keyRemove.hidden = !state.hasKey;148+ return button;
88- setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove);
89 }149 }
90 150
91 function renderFavorites() {151 function renderFavorites() {
92- elements.favoritesList.replaceChildren();152+ elements.favorites_list.replaceChildren();
93- elements.favoritesEmpty.hidden = state.favorites.length > 0;153+ elements.favorites_empty.hidden = state.favorites.length > 0;
154+ elements.add_section.hidden = state.favorites.length >= LIMITS.favorites;
155+ if (state.favorites.length >= LIMITS.favorites) setText(elements.catalog_status, copy.favoriteLimit);
156+ else if (elements.catalog_status.textContent === copy.favoriteLimit) setText(elements.catalog_status, "");
157+
94 state.favorites.forEach((favorite, index) => {158 state.favorites.forEach((favorite, index) => {
95- elements.favoritesList.append(favoriteItem(favorite, index));159+ const item = document.createElement("li");
160+ item.className = "favorite";
161+ const route = document.createElement("span");
162+ renderLineBadge(route, favorite);
163+ const labels = document.createElement("div");
164+ labels.className = "favorite-labels";
165+ const stop = document.createElement("strong");
166+ stop.textContent = favorite.stopLabel;
167+ const destination = document.createElement("span");
168+ destination.textContent = favorite.destinationLabel;
169+ labels.append(stop, destination);
170+
171+ const renameLabel = document.createElement("label");
172+ renameLabel.className = "visually-hidden";
173+ const renameId = `favorite-name-${index}`;
174+ renameLabel.htmlFor = renameId;
175+ renameLabel.textContent = copy.favoriteRename;
176+ const rename = document.createElement("input");
177+ rename.id = renameId;
178+ rename.type = "text";
179+ rename.autocomplete = "off";
180+ rename.maxLength = LIMITS.labelUtf8Bytes;
181+ rename.value = favorite.displayName ?? "";
182+ rename.placeholder = copy.favoriteNamePlaceholder;
183+ rename.addEventListener("change", () => {
184+ if (utf8Bytes(rename.value.trim()) <= LIMITS.labelUtf8Bytes) {
185+ dispatch({ type: "favorite-rename", id: favorite.id, displayName: rename.value });
186+ } else {
187+ rename.value = favorite.displayName ?? "";
188+ }
189+ });
190+
191+
192+ const actions = document.createElement("div");
193+ actions.className = "favorite-actions";
194+ actions.append(
195+ actionButton(copy.favoriteMoveUp, index === 0, () => dispatch({ type: "favorite-move", id: favorite.id, delta: -1 })),
196+ actionButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () => dispatch({ type: "favorite-move", id: favorite.id, delta: 1 })),
197+ actionButton(copy.favoriteRemove, false, () => dispatch({ type: "favorite-remove", id: favorite.id }), "quiet danger"),
198+ );
199+ item.append(route, labels, renameLabel, rename, actions);
200+ elements.favorites_list.append(item);
96 });201 });
97 }202 }
98 203
99-function favoriteItem(favorite, index) {204+function clearServiceSelection() {
100- const item = document.createElement("li");205+ if (serviceController !== null) serviceController.abort();
101- item.className = "favorite";206+ serviceController = null;
102-207+ services = [];
103- const heading = document.createElement("p");208+ selectedPlace = null;
104- heading.className = "labels";209+ selectedService = null;
105- heading.textContent = favorite.displayName ?? favorite.stopLabel;210+ elements.service_step.hidden = true;
106-211+ elements.preview.hidden = true;
107- const detail = document.createElement("p");212+ elements.service_select.replaceChildren();
108- detail.className = "sub";213+}
109- // Authored and transport labels cross every locale verbatim.
110- detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · ");
111-
112- const actions = document.createElement("div");
113- actions.className = "favorite-actions";
114- actions.append(
115- listButton(copy.favoriteMoveUp, index === 0, () =>
116- dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }),
117- ),
118- listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () =>
119- dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }),
120- ),
121- listButton(copy.favoriteRemove, false, () =>
122- dispatch({ type: "favorite-remove", id: favorite.id }),
123- ),
124- );
125 214
126- item.append(heading, detail, actions);215+function renderPlaces() {
127- return item;216+ elements.place_results.replaceChildren();
217+ for (const place of places) {
218+ const item = document.createElement("li");
219+ const label = place.localityLabel
220+ ? `${place.stopLabel} — ${place.localityLabel} · ${place.mode}`
221+ : `${place.stopLabel} · ${place.mode}`;
222+ item.append(actionButton(label, false, () => selectPlace(place), "choice"));
223+ elements.place_results.append(item);
224+ }
128 }225 }
129 226
130-function listButton(label, disabled, onClick) {227+async function selectPlace(place) {
131- const button = document.createElement("button");228+ clearServiceSelection();
132- button.type = "button";229+ selectedPlace = place;
133- button.textContent = label;230+ serviceController = new AbortController();
134- button.disabled = disabled;231+ const controller = serviceController;
135- button.addEventListener("click", onClick);232+ setText(elements.catalog_status, copy.servicesLoading);
136- return button;233+ try {
234+ services = await catalog.listServices(place.placeId, controller.signal);
235+ if (controller.signal.aborted) return;
236+ serviceController = null;
237+ if (services.length === 0) {
238+ setText(elements.catalog_status, copy.servicesEmpty);
239+ return;
240+ }
241+ elements.service_select.replaceChildren();
242+ const prompt = document.createElement("option");
243+ prompt.value = "";
244+ prompt.textContent = copy.servicesLabel;
245+ elements.service_select.append(prompt);
246+ services.forEach((service, index) => {
247+ const option = document.createElement("option");
248+ option.value = String(index);
249+ option.textContent = `${service.lineLabel} · ${service.destinationLabel}`;
250+ elements.service_select.append(option);
251+ });
252+ elements.service_step.hidden = false;
253+ setText(elements.catalog_status, "");
254+ elements.service_select.focus();
255+ } catch (error) {
256+ if (controller.signal.aborted) return;
257+ serviceController = null;
258+ setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_SERVICE"
259+ ? copy.invalidService
260+ : copy.backendUnavailable);
261+ }
262+}
263+
264+function renderPreview() {
265+ elements.preview.hidden = selectedService === null;
266+ if (selectedService === null) return;
267+ renderLineBadge(elements.preview_line, selectedService);
268+ setText(elements.preview_stop, selectedService.stopLabel);
269+ setText(elements.preview_destination, selectedService.destinationLabel);
270+ setText(
271+ elements.preview_departures,
272+ `${copy.previewRecorded}: ${RECORDED_PREVIEW.map((entry) => `${entry.minutes} ${copy.minutesShort}`).join(" · ")}`,
273+ );
274+}
275+
276+function nextFavoriteId() {
277+ let candidate;
278+ do {
279+ idSequence += 1;
280+ candidate = `cfg-${Date.now().toString(36)}-${idSequence.toString(36)}`;
281+ } while (state.favorites.some((favorite) => favorite.id === candidate));
282+ return candidate;
137 }283 }
138 284
139-elements.keyInput.addEventListener("input", () => {285+elements.key_input.addEventListener("input", () => {
140- elements.keyError.hidden = true;286+ elements.key_error.hidden = true;
141- dispatch({ type: "key-draft", value: elements.keyInput.value });287+ dispatch({ type: "key-draft", value: elements.key_input.value });
142 });288 });
143 289
144-elements.keyToggle.addEventListener("click", () => {290+elements.key_toggle.addEventListener("click", () => {
145- revealed = !revealed;291+ keyRevealed = !keyRevealed;
146- elements.keyInput.type = revealed ? "text" : "password";292+ elements.key_input.type = keyRevealed ? "text" : "password";
147- setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow);293+ elements.key_toggle.setAttribute("aria-pressed", String(keyRevealed));
148- elements.keyToggle.setAttribute("aria-pressed", String(revealed));294+ renderKey();
149- elements.keyInput.focus();295+ elements.key_input.focus();
150 });296 });
151 297
152-elements.keyRemove.addEventListener("click", () => {298+elements.key_remove.addEventListener("click", () => {
153- dispatch(299+ elements.key_input.value = "";
154- state.keyDraft.removeRequested300+ dispatch(state.keyDraft.removeRequested
155- ? { type: "key-remove-cancelled" }301+ ? { type: "key-remove-cancelled" }
156- : { type: "key-remove-requested" },302+ : { type: "key-remove-requested" });
157- );
158 });303 });
159 304
160-elements.save.addEventListener("click", () => {305+elements.place_search.addEventListener("input", async () => {
161- const outcome = planConfigResult(state);306+ const generation = ++searchGeneration;
162- if (!outcome.ok) {307+ clearServiceSelection();
163- elements.keyError.textContent = copy[outcome.error];308+ places = [];
164- elements.keyError.hidden = false;309+ renderPlaces();
165- elements.keyInput.focus();310+ const query = elements.place_search.value.trim();
311+ const length = Array.from(query).length;
312+ if (length < LIMITS.catalogQueryMinCharacters) {
313+ catalog.cancelSearch();
314+ setText(elements.catalog_status, "");
166 return;315 return;
167 }316 }
168- const fragment = session.close(outcome.payload);317+ setText(elements.catalog_status, copy.searchLoading);
169- if (fragment === null) {318+ try {
319+ const result = await catalog.searchPlaces(query);
320+ if (generation !== searchGeneration) return;
321+ places = result;
322+ renderPlaces();
323+ setText(elements.catalog_status, places.length === 0 ? copy.searchNoResults : "");
324+ } catch (error) {
325+ if (error?.name === "AbortError" || generation !== searchGeneration) return;
326+ setText(elements.catalog_status, error instanceof CatalogClientError && error.code === "INVALID_QUERY"
327+ ? copy.searchHint
328+ : copy.backendUnavailable);
329+ }
330+});
331+
332+elements.service_select.addEventListener("change", () => {
333+ const index = Number(elements.service_select.value);
334+ selectedService = Number.isInteger(index) && index >= 0 ? services[index] ?? null : null;
335+ renderPreview();
336+});
337+
338+elements.favorite_add.addEventListener("click", () => {
339+ if (selectedPlace === null || selectedService === null || !services.includes(selectedService)) {
340+ setText(elements.catalog_status, copy.invalidService);
170 return;341 return;
171 }342 }
343+ const name = elements.favorite_name.value.trim();
344+ if (name.length > 0 && utf8Bytes(name) > LIMITS.labelUtf8Bytes) {
345+ setText(elements.catalog_status, copy.invalidService);
346+ return;
347+ }
348+ const favorite = favoriteFromService(nextFavoriteId(), selectedService, state.favorites.length, name || undefined);
349+ if (favorite === null) {
350+ setText(elements.catalog_status, copy.invalidService);
351+ return;
352+ }
353+ dispatch({ type: "favorite-add", favorite });
354+ elements.favorite_name.value = "";
355+ elements.place_search.value = "";
356+ places = [];
357+ renderPlaces();
358+ clearServiceSelection();
359+ setText(elements.catalog_status, "");
360+});
361+
362+elements.about_open.addEventListener("click", () => {
363+ aboutReturnFocus = document.activeElement instanceof HTMLElement
364+ ? document.activeElement
365+ : elements.about_open;
366+ elements.config_view.hidden = true;
367+ elements.config_footer.hidden = true;
368+ elements.about_view.hidden = false;
369+ elements.about_back.focus();
370+});
371+
372+elements.about_back.addEventListener("click", () => {
373+ elements.about_view.hidden = true;
374+ elements.config_view.hidden = false;
375+ elements.config_footer.hidden = false;
376+ aboutReturnFocus.focus();
377+});
378+
379+elements.save.addEventListener("click", () => {
380+ const keyError = apiKeyError(state.keyDraft.value);
381+ if (keyError !== null) {
382+ setText(elements.key_error, copy[keyError]);
383+ elements.key_error.hidden = false;
384+ elements.key_input.focus();
385+ return;
386+ }
387+ const outcome = planConfigResult(state);
388+ if (!outcome.ok) return;
389+ const closeUrl = closeSession.close(outcome.payload);
390+ if (closeUrl === null) return;
172 elements.save.disabled = true;391 elements.save.disabled = true;
173- window.location.assign(fragment);392+ window.location.assign(closeUrl);
174 });393 });
175 394
176-applyStaticCopy();395+applyCopy();
177-render();396+renderKey();
397+renderFavorites();
398+setText(elements.catalog_status, "");
399+document.documentElement.classList.add("ready");
added packages/config-page/src/line-badge-assets.js +188 -0
new file mode 100644
@@ -0,0 +1,188 @@
1+// Generated by scripts/sync-line-badges.mjs. Do not edit.
2+const ASSETS = new Map([
3+ [
4+ "METRO:1",
5+ "metro-1.png"
6+ ],
7+ [
8+ "METRO:10",
9+ "metro-10.png"
10+ ],
11+ [
12+ "METRO:11",
13+ "metro-11.png"
14+ ],
15+ [
16+ "METRO:12",
17+ "metro-12.png"
18+ ],
19+ [
20+ "METRO:13",
21+ "metro-13.png"
22+ ],
23+ [
24+ "METRO:14",
25+ "metro-14.png"
26+ ],
27+ [
28+ "METRO:2",
29+ "metro-2.png"
30+ ],
31+ [
32+ "METRO:3",
33+ "metro-3.png"
34+ ],
35+ [
36+ "METRO:3B",
37+ "metro-3b.png"
38+ ],
39+ [
40+ "METRO:4",
41+ "metro-4.png"
42+ ],
43+ [
44+ "METRO:5",
45+ "metro-5.png"
46+ ],
47+ [
48+ "METRO:6",
49+ "metro-6.png"
50+ ],
51+ [
52+ "METRO:7",
53+ "metro-7.png"
54+ ],
55+ [
56+ "METRO:7B",
57+ "metro-7b.png"
58+ ],
59+ [
60+ "METRO:8",
61+ "metro-8.png"
62+ ],
63+ [
64+ "METRO:9",
65+ "metro-9.png"
66+ ],
67+ [
68+ "RER:A",
69+ "rer-a.png"
70+ ],
71+ [
72+ "RER:B",
73+ "rer-b.png"
74+ ],
75+ [
76+ "RER:C",
77+ "rer-c.png"
78+ ],
79+ [
80+ "RER:D",
81+ "rer-d.png"
82+ ],
83+ [
84+ "RER:E",
85+ "rer-e.png"
86+ ],
87+ [
88+ "TRAM:T1",
89+ "tram-t1.png"
90+ ],
91+ [
92+ "TRAM:T10",
93+ "tram-t10.png"
94+ ],
95+ [
96+ "TRAM:T11",
97+ "tram-t11.png"
98+ ],
99+ [
100+ "TRAM:T12",
101+ "tram-t12.png"
102+ ],
103+ [
104+ "TRAM:T13",
105+ "tram-t13.png"
106+ ],
107+ [
108+ "TRAM:T14",
109+ "tram-t14.svg"
110+ ],
111+ [
112+ "TRAM:T2",
113+ "tram-t2.png"
114+ ],
115+ [
116+ "TRAM:T3a",
117+ "tram-t3a.png"
118+ ],
119+ [
120+ "TRAM:T3b",
121+ "tram-t3b.png"
122+ ],
123+ [
124+ "TRAM:T4",
125+ "tram-t4.png"
126+ ],
127+ [
128+ "TRAM:T5",
129+ "tram-t5.png"
130+ ],
131+ [
132+ "TRAM:T6",
133+ "tram-t6.png"
134+ ],
135+ [
136+ "TRAM:T7",
137+ "tram-t7.png"
138+ ],
139+ [
140+ "TRAM:T8",
141+ "tram-t8.png"
142+ ],
143+ [
144+ "TRAM:T9",
145+ "tram-t9.png"
146+ ],
147+ [
148+ "TRANSILIEN:H",
149+ "transilien-h.png"
150+ ],
151+ [
152+ "TRANSILIEN:J",
153+ "transilien-j.png"
154+ ],
155+ [
156+ "TRANSILIEN:K",
157+ "transilien-k.png"
158+ ],
159+ [
160+ "TRANSILIEN:L",
161+ "transilien-l.png"
162+ ],
163+ [
164+ "TRANSILIEN:N",
165+ "transilien-n.png"
166+ ],
167+ [
168+ "TRANSILIEN:P",
169+ "transilien-p.png"
170+ ],
171+ [
172+ "TRANSILIEN:R",
173+ "transilien-r.png"
174+ ],
175+ [
176+ "TRANSILIEN:U",
177+ "transilien-u.png"
178+ ],
179+ [
180+ "TRANSILIEN:V",
181+ "transilien-v.png"
182+ ]
183+]);
184+
185+export function lineBadgeAssetUrl(lineMode, lineLabel) {
186+ const file = ASSETS.get(`${lineMode}:${lineLabel}`);
187+ return file === undefined ? undefined : new URL(`../assets/line-badges/${file}`, import.meta.url).href;
188+}
new file mode 100644
@@ -0,0 +1,188 @@
1+// Generated by scripts/sync-line-badges.mjs. Do not edit.
2+const ASSETS = new Map([
3+ [
4+ "METRO:1",
5+ "metro-1.png"
6+ ],
7+ [
8+ "METRO:10",
9+ "metro-10.png"
10+ ],
11+ [
12+ "METRO:11",
13+ "metro-11.png"
14+ ],
15+ [
16+ "METRO:12",
17+ "metro-12.png"
18+ ],
19+ [
20+ "METRO:13",
21+ "metro-13.png"
22+ ],
23+ [
24+ "METRO:14",
25+ "metro-14.png"
26+ ],
27+ [
28+ "METRO:2",
29+ "metro-2.png"
30+ ],
31+ [
32+ "METRO:3",
33+ "metro-3.png"
34+ ],
35+ [
36+ "METRO:3B",
37+ "metro-3b.png"
38+ ],
39+ [
40+ "METRO:4",
41+ "metro-4.png"
42+ ],
43+ [
44+ "METRO:5",
45+ "metro-5.png"
46+ ],
47+ [
48+ "METRO:6",
49+ "metro-6.png"
50+ ],
51+ [
52+ "METRO:7",
53+ "metro-7.png"
54+ ],
55+ [
56+ "METRO:7B",
57+ "metro-7b.png"
58+ ],
59+ [
60+ "METRO:8",
61+ "metro-8.png"
62+ ],
63+ [
64+ "METRO:9",
65+ "metro-9.png"
66+ ],
67+ [
68+ "RER:A",
69+ "rer-a.png"
70+ ],
71+ [
72+ "RER:B",
73+ "rer-b.png"
74+ ],
75+ [
76+ "RER:C",
77+ "rer-c.png"
78+ ],
79+ [
80+ "RER:D",
81+ "rer-d.png"
82+ ],
83+ [
84+ "RER:E",
85+ "rer-e.png"
86+ ],
87+ [
88+ "TRAM:T1",
89+ "tram-t1.png"
90+ ],
91+ [
92+ "TRAM:T10",
93+ "tram-t10.png"
94+ ],
95+ [
96+ "TRAM:T11",
97+ "tram-t11.png"
98+ ],
99+ [
100+ "TRAM:T12",
101+ "tram-t12.png"
102+ ],
103+ [
104+ "TRAM:T13",
105+ "tram-t13.png"
106+ ],
107+ [
108+ "TRAM:T14",
109+ "tram-t14.svg"
110+ ],
111+ [
112+ "TRAM:T2",
113+ "tram-t2.png"
114+ ],
115+ [
116+ "TRAM:T3a",
117+ "tram-t3a.png"
118+ ],
119+ [
120+ "TRAM:T3b",
121+ "tram-t3b.png"
122+ ],
123+ [
124+ "TRAM:T4",
125+ "tram-t4.png"
126+ ],
127+ [
128+ "TRAM:T5",
129+ "tram-t5.png"
130+ ],
131+ [
132+ "TRAM:T6",
133+ "tram-t6.png"
134+ ],
135+ [
136+ "TRAM:T7",
137+ "tram-t7.png"
138+ ],
139+ [
140+ "TRAM:T8",
141+ "tram-t8.png"
142+ ],
143+ [
144+ "TRAM:T9",
145+ "tram-t9.png"
146+ ],
147+ [
148+ "TRANSILIEN:H",
149+ "transilien-h.png"
150+ ],
151+ [
152+ "TRANSILIEN:J",
153+ "transilien-j.png"
154+ ],
155+ [
156+ "TRANSILIEN:K",
157+ "transilien-k.png"
158+ ],
159+ [
160+ "TRANSILIEN:L",
161+ "transilien-l.png"
162+ ],
163+ [
164+ "TRANSILIEN:N",
165+ "transilien-n.png"
166+ ],
167+ [
168+ "TRANSILIEN:P",
169+ "transilien-p.png"
170+ ],
171+ [
172+ "TRANSILIEN:R",
173+ "transilien-r.png"
174+ ],
175+ [
176+ "TRANSILIEN:U",
177+ "transilien-u.png"
178+ ],
179+ [
180+ "TRANSILIEN:V",
181+ "transilien-v.png"
182+ ]
183+]);
184+
185+export function lineBadgeAssetUrl(lineMode, lineLabel) {
186+ const file = ASSETS.get(`${lineMode}:${lineLabel}`);
187+ return file === undefined ? undefined : new URL(`../assets/line-badges/${file}`, import.meta.url).href;
188+}
added packages/config-page/src/preview-fixture.js +7 -0
new file mode 100644
@@ -0,0 +1,7 @@
1+// Public, credential-free projection of fixtures/departures/foundation.json.
2+// The test suite keeps this display-only preview synchronized with the recorded
3+// departure fixture without making live departure normalization a page dependency.
4+export const RECORDED_PREVIEW = Object.freeze([
5+ Object.freeze({ minutes: 2, status: "DELAYED" }),
6+ Object.freeze({ minutes: 6, status: "ON_TIME" }),
7+]);
new file mode 100644
@@ -0,0 +1,7 @@
1+// Public, credential-free projection of fixtures/departures/foundation.json.
2+// The test suite keeps this display-only preview synchronized with the recorded
3+// departure fixture without making live departure normalization a page dependency.
4+export const RECORDED_PREVIEW = Object.freeze([
5+ Object.freeze({ minutes: 2, status: "DELAYED" }),
6+ Object.freeze({ minutes: 6, status: "ON_TIME" }),
7+]);
modified packages/config-page/styles/config-page.css +189 -138
@@ -1,181 +1,232 @@
1-/* Lapin Futé configuration page: mobile-first, framework-free. */
2-
31 :root {
4- color-scheme: light dark;
5- --bg: #f4f4f2;
6- --surface: #ffffff;
7- --text: #1c1c1e;
8- --muted: #5c5c60;
9- --accent: #0a6ebd;
10- --on-accent: #ffffff;
11- --danger: #b3261e;
12- --border: #d9d9de;
13-}
14-
15-@media (prefers-color-scheme: dark) {
16- :root {
17- --bg: #141416;
18- --surface: #1f1f23;
19- --text: #f2f2f4;
20- --muted: #a0a0a8;
21- --accent: #6db3f2;
22- --on-accent: #0b0b0d;
23- --danger: #f2b8b5;
24- --border: #3a3a40;
25- }
26-}
27-
28-* {
29- box-sizing: border-box;
2+ color-scheme: light;
3+ --navy: #102a43;
4+ --blue: #1769aa;
5+ --blue-soft: #eaf3fa;
6+ --red: #c7353a;
7+ --ink: #17212b;
8+ --muted: #52616f;
9+ --line: #cad6e0;
10+ --paper: #f4f7f9;
11+ --white: #ffffff;
12+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
3013 }
3114
15+* { box-sizing: border-box; }
16+
17+html:not(.ready) body { visibility: hidden; }
18+
3219 body {
3320 margin: 0;
34- background: var(--bg);
35- color: var(--text);
36- font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
37- -webkit-text-size-adjust: 100%;
21+ min-width: 18rem;
22+ background: var(--paper);
23+ color: var(--ink);
24+ font-size: 1rem;
25+ line-height: 1.45;
3826 }
3927
40-main {
41- max-width: 30rem;
42- margin: 0 auto;
43- padding: 1rem 1rem 2rem;
44-}
28+button, input, select { font: inherit; }
29+button, a { -webkit-tap-highlight-color: transparent; }
4530
46-h1 {
47- font-size: 1.35rem;
48- margin: 0.25rem 0 1rem;
31+.masthead {
32+ display: flex;
33+ gap: 0.875rem;
34+ align-items: center;
35+ padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem;
36+ background: var(--navy);
37+ color: var(--white);
38+ border-bottom: 0.25rem solid var(--red);
39+}
40+
41+.route-mark {
42+ display: inline-grid;
43+ place-items: center;
44+ flex: 0 0 auto;
45+ min-width: 2.5rem;
46+ min-height: 2.5rem;
47+ padding: 0.25rem 0.5rem;
48+ border-radius: 0.35rem;
49+ background: var(--blue);
50+ color: var(--white);
51+ font-weight: 800;
52+ letter-spacing: -0.02em;
53+}
54+
55+.line-badge {
56+ display: inline-grid;
57+ place-items: center;
58+ flex: 0 0 auto;
59+ min-width: 2.5rem;
60+ min-height: 2.5rem;
61+ vertical-align: middle;
62+}
63+
64+.line-badge img, .line-badge svg {
65+ display: block;
66+ width: auto;
67+ height: 2.5rem;
68+ max-width: 8rem;
4969 }
5070
51-h2 {
52- font-size: 1.05rem;
53- margin: 0 0 0.5rem;
71+h1, h2, p { margin-top: 0; }
72+h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; }
73+.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; }
74+
75+main {
76+ width: min(100%, 38rem);
77+ margin: 0 auto;
78+ padding: 0.75rem;
5479 }
5580
5681 section {
57- background: var(--surface);
58- border: 1px solid var(--border);
59- border-radius: 0.75rem;
82+ margin-bottom: 0.75rem;
6083 padding: 1rem;
61- margin-bottom: 1rem;
84+ background: var(--white);
85+ border: 1px solid var(--line);
86+ border-radius: 0.5rem;
6287 }
6388
64-.status {
65- color: var(--muted);
66- margin: 0 0 0.75rem;
89+.section-heading {
90+ display: flex;
91+ align-items: baseline;
92+ gap: 0.625rem;
93+ margin-bottom: 0.75rem;
94+ border-bottom: 1px solid var(--line);
6795 }
6896
97+.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; }
98+h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; }
99+h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; }
100+
69101 .field-label {
70102 display: block;
71- margin: 0 0 0.35rem;
72- font-weight: 600;
103+ margin: 0.8rem 0 0.35rem;
104+ color: var(--navy);
105+ font-size: 0.88rem;
106+ font-weight: 700;
73107 }
74108
75-.key-row {
76- display: flex;
77- gap: 0.5rem;
78-}
79-
80-#key-input {
81- flex: 1;
82- min-width: 0;
109+input, select {
110+ width: 100%;
83111 min-height: 2.75rem;
84- padding: 0 0.75rem;
85- border: 1px solid var(--border);
86- border-radius: 0.5rem;
87- background: var(--bg);
88- color: var(--text);
89- font: inherit;
112+ padding: 0.65rem 0.75rem;
113+ border: 1px solid #8fa3b5;
114+ border-radius: 0.35rem;
115+ background: var(--white);
116+ color: var(--ink);
90117 }
91118
92-#key-input:focus-visible,
93-button:focus-visible {
94- outline: 2px solid var(--accent);
95- outline-offset: 1px;
119+input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible {
120+ outline: 0.2rem solid #ffb81c;
121+ outline-offset: 0.15rem;
96122 }
97123
124+.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
125+.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; }
126+.status, .support { color: var(--muted); }
127+.pending { color: var(--blue); font-weight: 700; }
128+.error { color: #9b1c24; font-weight: 700; }
129+a { color: var(--blue); font-weight: 700; }
130+
98131 button {
99132 min-height: 2.75rem;
100- padding: 0 0.9rem;
101- border: 1px solid var(--border);
102- border-radius: 0.5rem;
103- background: var(--surface);
104- color: var(--text);
105- font: inherit;
106-}
107-
108-button:disabled {
109- opacity: 0.5;
110-}
111-
112-.primary {
113- width: 100%;
114- background: var(--accent);
115- border-color: var(--accent);
116- color: var(--on-accent);
117- font-weight: 600;
118-}
119-
120-.danger {
121- color: var(--danger);
122- border-color: var(--danger);
123-}
124-
125-.error {
126- color: var(--danger);
127- margin: 0.5rem 0 0;
128-}
129-
130-.pending {
131- color: var(--danger);
132- margin: 0.5rem 0;
133-}
134-
135-#key-remove {
136- margin-top: 0.25rem;
137-}
138-
139-#favorites-list {
140- list-style: none;
133+ padding: 0.55rem 0.8rem;
134+ border: 1px solid transparent;
135+ border-radius: 0.35rem;
136+ cursor: pointer;
137+ font-weight: 750;
138+}
139+button:disabled { cursor: default; opacity: 0.45; }
140+.quiet { border-color: var(--line); background: var(--white); color: var(--navy); }
141+.danger { color: #9b1c24; }
142+#key-remove { margin-top: 0.25rem; }
143+
144+.favorites, .choice-list {
145+ display: grid;
146+ gap: 0.55rem;
141147 margin: 0;
142148 padding: 0;
143- display: grid;
144- gap: 0.5rem;
149+ list-style: none;
145150 }
146151
147152 .favorite {
148- border: 1px solid var(--border);
149- border-radius: 0.5rem;
150- padding: 0.5rem 0.75rem;
153+ display: grid;
154+ grid-template-columns: auto 1fr;
155+ gap: 0.65rem;
156+ padding: 0.75rem 0;
157+ border-bottom: 1px solid var(--line);
158+}
159+.favorite:last-child { border-bottom: 0; }
160+.favorite-labels { display: grid; align-content: center; min-width: 0; }
161+.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; }
162+.favorite-labels span { color: var(--muted); font-size: 0.85rem; }
163+.favorite > input { grid-column: 1 / -1; }
164+.favorite-actions {
165+ display: grid;
166+ grid-column: 1 / -1;
167+ grid-template-columns: repeat(3, 1fr);
168+ gap: 0.4rem;
169+}
170+.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; }
171+
172+.choice-list { margin-top: 0.6rem; }
173+.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; }
174+
175+.preview {
176+ margin-top: 0.85rem;
177+ padding: 0.8rem;
178+ border-left: 0.3rem solid var(--red);
179+ background: var(--blue-soft);
180+}
181+.preview p { margin-bottom: 0.35rem; }
182+.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; }
183+.preview .line-badge img, .preview .line-badge svg { height: 2rem; }
184+.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
185+.destination { padding-left: 2.85rem; color: var(--muted); }
186+.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
187+.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); }
188+
189+footer {
190+ position: sticky;
191+ bottom: 0;
192+ padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
193+ background: rgba(244, 247, 249, 0.96);
194+ border-top: 1px solid var(--line);
195+}
196+.footer-actions {
197+ display: grid;
198+ grid-template-columns: auto minmax(0, 1fr);
199+ gap: 0.5rem;
200+ width: min(100%, 36.5rem);
201+ margin: 0 auto;
151202 }
203+.primary { display: block; width: 100%; background: var(--red); color: var(--white); }
152204
153-.favorite .labels {
154- margin: 0;
155- font-weight: 600;
156-}
205+.about-view { overflow-wrap: anywhere; }
206+.about-heading { margin-top: 1rem; }
207+.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; }
208+.about-view p { margin-bottom: 0.65rem; }
157209
158-.favorite .sub {
159- margin: 0.1rem 0 0;
160- color: var(--muted);
161- font-size: 0.9rem;
162- overflow-wrap: anywhere;
210+.visually-hidden {
211+ position: absolute;
212+ width: 1px;
213+ height: 1px;
214+ padding: 0;
215+ margin: -1px;
216+ overflow: hidden;
217+ clip: rect(0, 0, 0, 0);
218+ white-space: nowrap;
219+ border: 0;
163220 }
164221
165-.favorite-actions {
166- display: flex;
167- gap: 0.5rem;
168- margin-top: 0.5rem;
169-}
222+[hidden] { display: none !important; }
170223
171-.favorite-actions button {
172- min-height: 2.25rem;
173- padding: 0 0.6rem;
174- font-size: 0.9rem;
224+@media (min-width: 32rem) {
225+ main { padding: 1rem; }
226+ section { padding: 1.2rem; }
227+ .masthead { padding-inline: calc((100% - 36rem) / 2); }
175228 }
176229
177-@media (min-width: 30rem) {
178- main {
179- padding-top: 2rem;
180- }
230+@media (prefers-reduced-motion: reduce) {
231+ *, *::before, *::after { scroll-behavior: auto !important; }
181232 }
@@ -1,181 +1,232 @@
1-/* Lapin Futé configuration page: mobile-first, framework-free. */
2-
3 :root {1 :root {
4- color-scheme: light dark;2+ color-scheme: light;
5- --bg: #f4f4f2;3+ --navy: #102a43;
6- --surface: #ffffff;4+ --blue: #1769aa;
7- --text: #1c1c1e;5+ --blue-soft: #eaf3fa;
8- --muted: #5c5c60;6+ --red: #c7353a;
9- --accent: #0a6ebd;7+ --ink: #17212b;
10- --on-accent: #ffffff;8+ --muted: #52616f;
11- --danger: #b3261e;9+ --line: #cad6e0;
12- --border: #d9d9de;10+ --paper: #f4f7f9;
13-}11+ --white: #ffffff;
14-12+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
15-@media (prefers-color-scheme: dark) {
16- :root {
17- --bg: #141416;
18- --surface: #1f1f23;
19- --text: #f2f2f4;
20- --muted: #a0a0a8;
21- --accent: #6db3f2;
22- --on-accent: #0b0b0d;
23- --danger: #f2b8b5;
24- --border: #3a3a40;
25- }
26-}
27-
28-* {
29- box-sizing: border-box;
30 }13 }
31 14
15+* { box-sizing: border-box; }
16+
17+html:not(.ready) body { visibility: hidden; }
18+
32 body {19 body {
33 margin: 0;20 margin: 0;
34- background: var(--bg);21+ min-width: 18rem;
35- color: var(--text);22+ background: var(--paper);
36- font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;23+ color: var(--ink);
37- -webkit-text-size-adjust: 100%;24+ font-size: 1rem;
25+ line-height: 1.45;
38 }26 }
39 27
40-main {28+button, input, select { font: inherit; }
41- max-width: 30rem;29+button, a { -webkit-tap-highlight-color: transparent; }
42- margin: 0 auto;
43- padding: 1rem 1rem 2rem;
44-}
45 30
46-h1 {31+.masthead {
47- font-size: 1.35rem;32+ display: flex;
48- margin: 0.25rem 0 1rem;33+ gap: 0.875rem;
34+ align-items: center;
35+ padding: max(1rem, env(safe-area-inset-top)) 1rem 1rem;
36+ background: var(--navy);
37+ color: var(--white);
38+ border-bottom: 0.25rem solid var(--red);
39+}
40+
41+.route-mark {
42+ display: inline-grid;
43+ place-items: center;
44+ flex: 0 0 auto;
45+ min-width: 2.5rem;
46+ min-height: 2.5rem;
47+ padding: 0.25rem 0.5rem;
48+ border-radius: 0.35rem;
49+ background: var(--blue);
50+ color: var(--white);
51+ font-weight: 800;
52+ letter-spacing: -0.02em;
53+}
54+
55+.line-badge {
56+ display: inline-grid;
57+ place-items: center;
58+ flex: 0 0 auto;
59+ min-width: 2.5rem;
60+ min-height: 2.5rem;
61+ vertical-align: middle;
62+}
63+
64+.line-badge img, .line-badge svg {
65+ display: block;
66+ width: auto;
67+ height: 2.5rem;
68+ max-width: 8rem;
49 }69 }
50 70
51-h2 {71+h1, h2, p { margin-top: 0; }
52- font-size: 1.05rem;72+h1 { margin-bottom: 0.15rem; font-size: 1.45rem; line-height: 1.1; }
53- margin: 0 0 0.5rem;73+.masthead p { margin: 0; color: #dbe9f4; font-size: 0.88rem; }
74+
75+main {
76+ width: min(100%, 38rem);
77+ margin: 0 auto;
78+ padding: 0.75rem;
54 }79 }
55 80
56 section {81 section {
57- background: var(--surface);82+ margin-bottom: 0.75rem;
58- border: 1px solid var(--border);
59- border-radius: 0.75rem;
60 padding: 1rem;83 padding: 1rem;
61- margin-bottom: 1rem;84+ background: var(--white);
85+ border: 1px solid var(--line);
86+ border-radius: 0.5rem;
62 }87 }
63 88
64-.status {89+.section-heading {
65- color: var(--muted);90+ display: flex;
66- margin: 0 0 0.75rem;91+ align-items: baseline;
92+ gap: 0.625rem;
93+ margin-bottom: 0.75rem;
94+ border-bottom: 1px solid var(--line);
67 }95 }
68 96
97+.step { color: var(--blue); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.08em; }
98+h2 { margin-bottom: 0.55rem; color: var(--navy); font-size: 1.05rem; }
99+h3 { margin: 1.15rem 0 0.45rem; color: var(--navy); font-size: 0.98rem; }
100+
69 .field-label {101 .field-label {
70 display: block;102 display: block;
71- margin: 0 0 0.35rem;103+ margin: 0.8rem 0 0.35rem;
72- font-weight: 600;104+ color: var(--navy);
105+ font-size: 0.88rem;
106+ font-weight: 700;
73 }107 }
74 108
75-.key-row {109+input, select {
76- display: flex;110+ width: 100%;
77- gap: 0.5rem;
78-}
79-
80-#key-input {
81- flex: 1;
82- min-width: 0;
83 min-height: 2.75rem;111 min-height: 2.75rem;
84- padding: 0 0.75rem;112+ padding: 0.65rem 0.75rem;
85- border: 1px solid var(--border);113+ border: 1px solid #8fa3b5;
86- border-radius: 0.5rem;114+ border-radius: 0.35rem;
87- background: var(--bg);115+ background: var(--white);
88- color: var(--text);116+ color: var(--ink);
89- font: inherit;
90 }117 }
91 118
92-#key-input:focus-visible,119+input:focus-visible, select:focus-visible, button:focus-visible, a:focus-visible {
93-button:focus-visible {120+ outline: 0.2rem solid #ffb81c;
94- outline: 2px solid var(--accent);121+ outline-offset: 0.15rem;
95- outline-offset: 1px;
96 }122 }
97 123
124+.key-row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
125+.status, .support, .pending, .error { margin-bottom: 0.55rem; font-size: 0.86rem; }
126+.status, .support { color: var(--muted); }
127+.pending { color: var(--blue); font-weight: 700; }
128+.error { color: #9b1c24; font-weight: 700; }
129+a { color: var(--blue); font-weight: 700; }
130+
98 button {131 button {
99 min-height: 2.75rem;132 min-height: 2.75rem;
100- padding: 0 0.9rem;133+ padding: 0.55rem 0.8rem;
101- border: 1px solid var(--border);134+ border: 1px solid transparent;
102- border-radius: 0.5rem;135+ border-radius: 0.35rem;
103- background: var(--surface);136+ cursor: pointer;
104- color: var(--text);137+ font-weight: 750;
105- font: inherit;138+}
106-}139+button:disabled { cursor: default; opacity: 0.45; }
107-140+.quiet { border-color: var(--line); background: var(--white); color: var(--navy); }
108-button:disabled {141+.danger { color: #9b1c24; }
109- opacity: 0.5;142+#key-remove { margin-top: 0.25rem; }
110-}143+
111-144+.favorites, .choice-list {
112-.primary {145+ display: grid;
113- width: 100%;146+ gap: 0.55rem;
114- background: var(--accent);
115- border-color: var(--accent);
116- color: var(--on-accent);
117- font-weight: 600;
118-}
119-
120-.danger {
121- color: var(--danger);
122- border-color: var(--danger);
123-}
124-
125-.error {
126- color: var(--danger);
127- margin: 0.5rem 0 0;
128-}
129-
130-.pending {
131- color: var(--danger);
132- margin: 0.5rem 0;
133-}
134-
135-#key-remove {
136- margin-top: 0.25rem;
137-}
138-
139-#favorites-list {
140- list-style: none;
141 margin: 0;147 margin: 0;
142 padding: 0;148 padding: 0;
143- display: grid;149+ list-style: none;
144- gap: 0.5rem;
145 }150 }
146 151
147 .favorite {152 .favorite {
148- border: 1px solid var(--border);153+ display: grid;
149- border-radius: 0.5rem;154+ grid-template-columns: auto 1fr;
150- padding: 0.5rem 0.75rem;155+ gap: 0.65rem;
156+ padding: 0.75rem 0;
157+ border-bottom: 1px solid var(--line);
158+}
159+.favorite:last-child { border-bottom: 0; }
160+.favorite-labels { display: grid; align-content: center; min-width: 0; }
161+.favorite-labels strong, .favorite-labels span { overflow-wrap: anywhere; }
162+.favorite-labels span { color: var(--muted); font-size: 0.85rem; }
163+.favorite > input { grid-column: 1 / -1; }
164+.favorite-actions {
165+ display: grid;
166+ grid-column: 1 / -1;
167+ grid-template-columns: repeat(3, 1fr);
168+ gap: 0.4rem;
169+}
170+.favorite-actions button { min-height: 2.5rem; padding-inline: 0.35rem; font-size: 0.78rem; }
171+
172+.choice-list { margin-top: 0.6rem; }
173+.choice { width: 100%; border-color: var(--line); background: var(--blue-soft); color: var(--navy); text-align: left; }
174+
175+.preview {
176+ margin-top: 0.85rem;
177+ padding: 0.8rem;
178+ border-left: 0.3rem solid var(--red);
179+ background: var(--blue-soft);
180+}
181+.preview p { margin-bottom: 0.35rem; }
182+.preview .line-badge { min-width: 2rem; min-height: 2rem; margin-right: 0.35rem; }
183+.preview .line-badge img, .preview .line-badge svg { height: 2rem; }
184+.eyebrow { color: var(--blue); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
185+.destination { padding-left: 2.85rem; color: var(--muted); }
186+.recorded-preview { color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
187+.secondary { width: 100%; margin-top: 0.75rem; background: var(--blue); color: var(--white); }
188+
189+footer {
190+ position: sticky;
191+ bottom: 0;
192+ padding: 0.65rem max(0.75rem, env(safe-area-inset-right)) max(0.65rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
193+ background: rgba(244, 247, 249, 0.96);
194+ border-top: 1px solid var(--line);
195+}
196+.footer-actions {
197+ display: grid;
198+ grid-template-columns: auto minmax(0, 1fr);
199+ gap: 0.5rem;
200+ width: min(100%, 36.5rem);
201+ margin: 0 auto;
151 }202 }
203+.primary { display: block; width: 100%; background: var(--red); color: var(--white); }
152 204
153-.favorite .labels {205+.about-view { overflow-wrap: anywhere; }
154- margin: 0;206+.about-heading { margin-top: 1rem; }
155- font-weight: 600;207+.about-version { color: var(--muted); font-size: 0.86rem; font-weight: 700; }
156-}208+.about-view p { margin-bottom: 0.65rem; }
157 209
158-.favorite .sub {210+.visually-hidden {
159- margin: 0.1rem 0 0;211+ position: absolute;
160- color: var(--muted);212+ width: 1px;
161- font-size: 0.9rem;213+ height: 1px;
162- overflow-wrap: anywhere;214+ padding: 0;
215+ margin: -1px;
216+ overflow: hidden;
217+ clip: rect(0, 0, 0, 0);
218+ white-space: nowrap;
219+ border: 0;
163 }220 }
164 221
165-.favorite-actions {222+[hidden] { display: none !important; }
166- display: flex;
167- gap: 0.5rem;
168- margin-top: 0.5rem;
169-}
170 223
171-.favorite-actions button {224+@media (min-width: 32rem) {
172- min-height: 2.25rem;225+ main { padding: 1rem; }
173- padding: 0 0.6rem;226+ section { padding: 1.2rem; }
174- font-size: 0.9rem;227+ .masthead { padding-inline: calc((100% - 36rem) / 2); }
175 }228 }
176 229
177-@media (min-width: 30rem) {230+@media (prefers-reduced-motion: reduce) {
178- main {231+ *, *::before, *::after { scroll-behavior: auto !important; }
179- padding-top: 2rem;
180- }
181 }232 }
modified packages/config-page/test/config-page.test.ts +245 -6
@@ -23,8 +23,11 @@ import {
2323 copyFor,
2424 createCloseSession,
2525 encodeCloseFragment,
26+ favoriteFromService,
2627 initialConfigState,
2728 isFavoriteShape,
29+ isPlaceSearchResult,
30+ isServiceOptionsResult,
2831 parseConfigFragment,
2932 planApiKeyUpdate,
3033 planConfigResult,
@@ -32,12 +35,34 @@ import {
3235 selectLocale,
3336 utf8Bytes,
3437 } from "../src/config-core.js";
38+import {
39+ SEARCH_DEBOUNCE_MS,
40+ CatalogClientError,
41+ createCatalogClient,
42+} from "../src/catalog-client.js";
43+import { RECORDED_PREVIEW } from "../src/preview-fixture.js";
44+import { lineBadgeAssetUrl } from "../src/line-badge-assets.js";
3545
3646 const here = dirname(fileURLToPath(import.meta.url));
3747 const require = createRequire(import.meta.url);
3848 const companionConfiguration = require("../../companion/src/configuration.js");
3949
4050 function favorite(id: string, sortOrder: number): Favorite {
51+ return {
52+ schemaVersion: 1,
53+ id,
54+ serviceId: `service:${id}`,
55+ stopLabel: `Arrêt ${id}`,
56+ lineLabel: "Métro 1",
57+ destinationLabel: "La Défense",
58+ lineMode: "METRO",
59+ lineColor: "#ffbe00",
60+ lineTextColor: "#000000",
61+ sortOrder,
62+ };
63+}
64+
65+function legacyFavorite(id: string, sortOrder: number): Favorite {
4166 return {
4267 schemaVersion: 1,
4368 id,
@@ -66,6 +91,9 @@ test("mirrored constants equal the canonical contract", () => {
6691 assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes);
6792 assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes);
6893 assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites);
94+ assert.equal(LIMITS.catalogQueryMinCharacters, CANONICAL_LIMITS.catalogQueryMinCharacters);
95+ assert.equal(LIMITS.catalogQueryMaxCharacters, CANONICAL_LIMITS.catalogQueryMaxCharacters);
96+ assert.equal(LIMITS.catalogSearchResults, CANONICAL_LIMITS.catalogSearchResults);
6997 });
7098
7199 test("french language tags select french copy and everything else falls back to english", () => {
@@ -127,6 +155,43 @@ test("opening fragment parsing is secret-free and validates favorites", () => {
127155 );
128156 assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]);
129157 });
158+test("legacy favorites round trip without inventing presentation properties and partial groups reject", () => {
159+ const legacy = legacyFavorite("legacy", 0);
160+ const parsed = parseConfigFragment(fragmentWith([legacy]));
161+ const presentationFields = ["lineMode", "lineColor", "lineTextColor"] as const;
162+
163+ assert.equal(isFavorite(legacy), true);
164+ assert.equal(isFavoriteShape(legacy), true);
165+ assert.deepEqual(parsed.favorites, [legacy]);
166+ for (const field of presentationFields) {
167+ assert.equal(Object.hasOwn(parsed.favorites[0], field), false);
168+ }
169+
170+ const partials = [
171+ { ...legacy, lineMode: "METRO" },
172+ { ...legacy, lineMode: "METRO", lineColor: "#ffbe00" },
173+ { ...legacy, lineColor: "#ffbe00", lineTextColor: "#000000" },
174+ ];
175+ for (const partial of partials) {
176+ assert.equal(isFavorite(partial), false);
177+ assert.equal(isFavoriteShape(partial), false);
178+ assert.deepEqual(parseConfigFragment(openingFragment({
179+ hasKey: true,
180+ favorites: [partial],
181+ language: "fr_FR",
182+ })).favorites, []);
183+ }
184+ assert.equal(isFavorite({ ...legacy, lineMode: undefined }), false);
185+ assert.equal(isFavoriteShape({ ...legacy, lineMode: undefined }), false);
186+
187+ const outcome = planConfigResult(initialConfigState(parsed));
188+ assert.equal(outcome.ok, true);
189+ if (!outcome.ok) return;
190+ assert.deepEqual(outcome.payload.favorites, [legacy]);
191+ for (const field of presentationFields) {
192+ assert.equal(Object.hasOwn(outcome.payload.favorites[0], field), false);
193+ }
194+});
130195 test("the config page consumes the companion-produced opening fragment exactly", () => {
131196 const key = "stored-personal-key";
132197 const url = companionConfiguration.configurationUrl(
@@ -288,6 +353,9 @@ test("favorite edits replace the whole list atomically with renumbered order", (
288353 stopLabel: "Arrêt work",
289354 lineLabel: "Métro 1",
290355 destinationLabel: "La Défense",
356+ lineMode: "METRO",
357+ lineColor: "#ffbe00",
358+ lineTextColor: "#000000",
291359 sortOrder: 0,
292360 displayName: "Bureau",
293361 },
@@ -367,11 +435,182 @@ test("the close fragment carries the key value only for REPLACE and is one-shot"
367435 assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false);
368436 });
369437
370-test("page sources contain no storage, network, timer, or logging surface", () => {
371- const forbidden =
372- /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/;
373- for (const file of ["config-core.js", "config-page.js"]) {
374- const source = readFileSync(join(here, "../src", file), "utf8");
375- assert.doesNotMatch(source, forbidden);
438+test("three backend services can be added, renamed, reordered, and removed atomically", () => {
439+ const services = [
440+ { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
441+ { serviceId: "svc-b", stopLabel: "République", lineLabel: "96", destinationLabel: "Porte des Lilas", lineMode: "BUS", lineColor: "#007852", lineTextColor: "#ffffff" },
442+ { serviceId: "svc-c", stopLabel: "Nation", lineLabel: "A", destinationLabel: "Cergy", lineMode: "RER", lineColor: "#e3051c", lineTextColor: "#ffffff" },
443+ ];
444+ let state = initialConfigState(parseConfigFragment(fragmentWith([])));
445+ services.forEach((service, index) => {
446+ const entry = favoriteFromService(`cfg-${index}`, service, index);
447+ assert.ok(entry);
448+ state = reduceConfigState(state, { type: "favorite-add", favorite: entry });
449+ });
450+ assert.deepEqual(state.favorites.map((entry) => entry.serviceId), ["svc-a", "svc-b", "svc-c"]);
451+ assert.deepEqual(
452+ state.favorites.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
453+ services.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
454+ );
455+ state = reduceConfigState(state, { type: "favorite-rename", id: "cfg-1", displayName: "Travail" });
456+ state = reduceConfigState(state, { type: "favorite-move", id: "cfg-2", delta: -2 });
457+ state = reduceConfigState(state, { type: "favorite-remove", id: "cfg-0" });
458+ assert.deepEqual(state.favorites.map((entry) => [entry.id, entry.sortOrder, entry.displayName]), [
459+ ["cfg-2", 0, undefined],
460+ ["cfg-1", 1, "Travail"],
461+ ]);
462+ const outcome = planConfigResult(state);
463+ assert.equal(outcome.ok, true);
464+ if (outcome.ok) assert.equal(outcome.payload.favorites.every(isFavorite), true);
465+});
466+
467+test("catalog response guards reject identifiers and malformed or oversized collections", () => {
468+ const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" };
469+ const service = { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" };
470+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true);
471+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false);
472+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false);
473+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true);
474+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false);
475+ assert.equal(favoriteFromService("cfg-a", { ...service, lineRef: "raw" }, 0), null);
476+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineColor: "#BE418D" }] }, "plc-a"), false);
477+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineMode: "metro" }] }, "plc-a"), false);
478+ const { lineTextColor: _missingTextColor, ...missingTextColor } = service;
479+ assert.equal(favoriteFromService("cfg-a", missingTextColor, 0), null);
480+});
481+
482+test("favorite preview uses the credential-free recorded departure fixture", () => {
483+ const recorded = JSON.parse(
484+ readFileSync(join(here, "../../../fixtures/departures/foundation.json"), "utf8"),
485+ );
486+ assert.deepEqual(
487+ RECORDED_PREVIEW,
488+ recorded.result.departures.map(({ minutes, status }: { minutes: number; status: string }) => ({ minutes, status })),
489+ );
490+ assert.equal(JSON.stringify(RECORDED_PREVIEW).includes(recorded.favorite.serviceId), false);
491+});
492+test("catalog search waits 300 ms, cancels superseded work, and validates responses", async () => {
493+ const timers = new Map<number, () => void>();
494+ const delays: number[] = [];
495+ let nextTimer = 0;
496+ const requests: string[] = [];
497+ const client = createCatalogClient({
498+ setTimer(callback: () => void, delay: number) {
499+ const id = ++nextTimer;
500+
501+ delays.push(delay);
502+ timers.set(id, callback);
503+ return id;
504+ },
505+ clearTimer(id: number) {
506+ timers.delete(id);
507+ },
508+ async fetchImpl(url: string) {
509+ requests.push(url);
510+ return {
511+ ok: true,
512+ async json() {
513+ return { schemaVersion: 1, places: [] };
514+ },
515+ };
516+ },
517+ });
518+ const superseded = client.searchPlaces("ch");
519+ const latest = client.searchPlaces("cha");
520+ await assert.rejects(superseded, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
521+ assert.deepEqual(delays, [SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS]);
522+ assert.equal(timers.size, 1);
523+ [...timers.values()][0]!();
524+ assert.deepEqual(await latest, []);
525+ assert.deepEqual(requests, ["/api/catalog/places?q=cha"]);
526+
527+ const invalid = createCatalogClient({
528+ setTimer(callback: () => void) {
529+ queueMicrotask(callback);
530+ return 1;
531+ },
532+ clearTimer() {},
533+ async fetchImpl() {
534+ return { ok: true, async json() { return { schemaVersion: 1, places: [{ placeId: "leak" }] }; } };
535+ },
536+ });
537+ await assert.rejects(invalid.searchPlaces("invalid"), (error: unknown) =>
538+ error instanceof CatalogClientError && error.code === "BACKEND_UNAVAILABLE");
539+});
540+
541+test("page keeps secrets out of durable and observable surfaces", () => {
542+ const core = readFileSync(join(here, "../src/config-core.js"), "utf8");
543+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
544+ const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8");
545+ const html = readFileSync(join(here, "../index.html"), "utf8");
546+ for (const source of [core, controller, client]) {
547+ assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u);
548+ }
549+ assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u);
550+ assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);
551+ assert.match(html, /connect-src 'self'/u);
552+ assert.doesNotMatch(html, /value=["'][^"']+["']/u);
553+ assert.match(html, /img-src 'self'/u);
554+ assert.doesNotMatch(html, /img-src 'none'/u);
555+});
556+
557+test("bilingual About copy exposes the required legal, privacy, attribution, and safe-link facts", () => {
558+ const html = readFileSync(join(here, "../index.html"), "utf8");
559+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
560+ for (const fact of [
561+ "Version 0.1.0",
562+ "Publication director:",
563+ "Directeur de la publication :",
564+ "Anthodev",
565+ "88 Colin P. Kelly Jr. St.",
566+ "localStorage",
567+ "no analytics or telemetry",
568+ "aucun cookie",
569+ "Référentiel des lignes",
570+ "conditions d’utilisation IDFM/PRIM",
571+ "search terms are sent",
572+ "termes saisis sont transmis",
573+ "ODbL 1.0",
574+ "github.com/Anthodev/lapin-fute",
575+ ]) assert.equal(html.includes(fact), true, fact);
576+ assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu);
577+ assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length);
578+ assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u);
579+ assert.match(controller, /image\.addEventListener\("error"/u);
580+ assert.match(controller, /elements\.config_view\.hidden = true/u);
581+ assert.match(controller, /aboutReturnFocus\.focus\(\)/u);
582+});
583+
584+test("official rail badges cover every RER and Transilien line", () => {
585+ for (const line of ["A", "B", "C", "D", "E"]) {
586+ assert.match(lineBadgeAssetUrl("RER", line) ?? "", new RegExp(`/rer-${line.toLowerCase()}\\.png$`, "u"));
587+ }
588+ for (const line of ["H", "J", "K", "L", "N", "P", "R", "U", "V"]) {
589+ assert.match(
590+ lineBadgeAssetUrl("TRANSILIEN", line) ?? "",
591+ new RegExp(`/transilien-${line.toLowerCase()}\\.png$`, "u"),
592+ );
376593 }
377594 });
595+
596+test("legacy favorites resolve no official image and use an accessible neutral fallback", () => {
597+ const legacy = legacyFavorite("legacy-badge", 0);
598+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
599+
600+ assert.equal(lineBadgeAssetUrl(legacy.lineMode, legacy.lineLabel), undefined);
601+ assert.match(
602+ controller,
603+ /const assetUrl = service\.lineMode === undefined\s+\? undefined\s+:\s+lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u,
604+ );
605+ assert.match(controller, /const NEUTRAL_LINE_BACKGROUND = "#52616f"/u);
606+ assert.match(controller, /const NEUTRAL_LINE_TEXT = "#ffffff"/u);
607+ assert.match(controller, /const backgroundColor = service\.lineColor \?\? NEUTRAL_LINE_BACKGROUND/u);
608+ assert.match(controller, /const textColor = service\.lineTextColor \?\? NEUTRAL_LINE_TEXT/u);
609+ assert.match(controller, /svg\.setAttribute\("role", "img"\)/u);
610+ assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
611+});
612+
613+test("the first-load gzip metric includes the eagerly imported badge resolver", () => {
614+ const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8");
615+ assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u);
616+});
@@ -23,8 +23,11 @@ import {
23 copyFor,23 copyFor,
24 createCloseSession,24 createCloseSession,
25 encodeCloseFragment,25 encodeCloseFragment,
26+ favoriteFromService,
26 initialConfigState,27 initialConfigState,
27 isFavoriteShape,28 isFavoriteShape,
29+ isPlaceSearchResult,
30+ isServiceOptionsResult,
28 parseConfigFragment,31 parseConfigFragment,
29 planApiKeyUpdate,32 planApiKeyUpdate,
30 planConfigResult,33 planConfigResult,
@@ -32,12 +35,34 @@ import {
32 selectLocale,35 selectLocale,
33 utf8Bytes,36 utf8Bytes,
34 } from "../src/config-core.js";37 } from "../src/config-core.js";
38+import {
39+ SEARCH_DEBOUNCE_MS,
40+ CatalogClientError,
41+ createCatalogClient,
42+} from "../src/catalog-client.js";
43+import { RECORDED_PREVIEW } from "../src/preview-fixture.js";
44+import { lineBadgeAssetUrl } from "../src/line-badge-assets.js";
35 45
36 const here = dirname(fileURLToPath(import.meta.url));46 const here = dirname(fileURLToPath(import.meta.url));
37 const require = createRequire(import.meta.url);47 const require = createRequire(import.meta.url);
38 const companionConfiguration = require("../../companion/src/configuration.js");48 const companionConfiguration = require("../../companion/src/configuration.js");
39 49
40 function favorite(id: string, sortOrder: number): Favorite {50 function favorite(id: string, sortOrder: number): Favorite {
51+ return {
52+ schemaVersion: 1,
53+ id,
54+ serviceId: `service:${id}`,
55+ stopLabel: `Arrêt ${id}`,
56+ lineLabel: "Métro 1",
57+ destinationLabel: "La Défense",
58+ lineMode: "METRO",
59+ lineColor: "#ffbe00",
60+ lineTextColor: "#000000",
61+ sortOrder,
62+ };
63+}
64+
65+function legacyFavorite(id: string, sortOrder: number): Favorite {
41 return {66 return {
42 schemaVersion: 1,67 schemaVersion: 1,
43 id,68 id,
@@ -66,6 +91,9 @@ test("mirrored constants equal the canonical contract", () => {
66 assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes);91 assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes);
67 assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes);92 assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes);
68 assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites);93 assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites);
94+ assert.equal(LIMITS.catalogQueryMinCharacters, CANONICAL_LIMITS.catalogQueryMinCharacters);
95+ assert.equal(LIMITS.catalogQueryMaxCharacters, CANONICAL_LIMITS.catalogQueryMaxCharacters);
96+ assert.equal(LIMITS.catalogSearchResults, CANONICAL_LIMITS.catalogSearchResults);
69 });97 });
70 98
71 test("french language tags select french copy and everything else falls back to english", () => {99 test("french language tags select french copy and everything else falls back to english", () => {
@@ -127,6 +155,43 @@ test("opening fragment parsing is secret-free and validates favorites", () => {
127 );155 );
128 assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]);156 assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]);
129 });157 });
158+test("legacy favorites round trip without inventing presentation properties and partial groups reject", () => {
159+ const legacy = legacyFavorite("legacy", 0);
160+ const parsed = parseConfigFragment(fragmentWith([legacy]));
161+ const presentationFields = ["lineMode", "lineColor", "lineTextColor"] as const;
162+
163+ assert.equal(isFavorite(legacy), true);
164+ assert.equal(isFavoriteShape(legacy), true);
165+ assert.deepEqual(parsed.favorites, [legacy]);
166+ for (const field of presentationFields) {
167+ assert.equal(Object.hasOwn(parsed.favorites[0], field), false);
168+ }
169+
170+ const partials = [
171+ { ...legacy, lineMode: "METRO" },
172+ { ...legacy, lineMode: "METRO", lineColor: "#ffbe00" },
173+ { ...legacy, lineColor: "#ffbe00", lineTextColor: "#000000" },
174+ ];
175+ for (const partial of partials) {
176+ assert.equal(isFavorite(partial), false);
177+ assert.equal(isFavoriteShape(partial), false);
178+ assert.deepEqual(parseConfigFragment(openingFragment({
179+ hasKey: true,
180+ favorites: [partial],
181+ language: "fr_FR",
182+ })).favorites, []);
183+ }
184+ assert.equal(isFavorite({ ...legacy, lineMode: undefined }), false);
185+ assert.equal(isFavoriteShape({ ...legacy, lineMode: undefined }), false);
186+
187+ const outcome = planConfigResult(initialConfigState(parsed));
188+ assert.equal(outcome.ok, true);
189+ if (!outcome.ok) return;
190+ assert.deepEqual(outcome.payload.favorites, [legacy]);
191+ for (const field of presentationFields) {
192+ assert.equal(Object.hasOwn(outcome.payload.favorites[0], field), false);
193+ }
194+});
130 test("the config page consumes the companion-produced opening fragment exactly", () => {195 test("the config page consumes the companion-produced opening fragment exactly", () => {
131 const key = "stored-personal-key";196 const key = "stored-personal-key";
132 const url = companionConfiguration.configurationUrl(197 const url = companionConfiguration.configurationUrl(
@@ -288,6 +353,9 @@ test("favorite edits replace the whole list atomically with renumbered order", (
288 stopLabel: "Arrêt work",353 stopLabel: "Arrêt work",
289 lineLabel: "Métro 1",354 lineLabel: "Métro 1",
290 destinationLabel: "La Défense",355 destinationLabel: "La Défense",
356+ lineMode: "METRO",
357+ lineColor: "#ffbe00",
358+ lineTextColor: "#000000",
291 sortOrder: 0,359 sortOrder: 0,
292 displayName: "Bureau",360 displayName: "Bureau",
293 },361 },
@@ -367,11 +435,182 @@ test("the close fragment carries the key value only for REPLACE and is one-shot"
367 assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false);435 assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false);
368 });436 });
369 437
370-test("page sources contain no storage, network, timer, or logging surface", () => {438+test("three backend services can be added, renamed, reordered, and removed atomically", () => {
371- const forbidden =439+ const services = [
372- /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/;440+ { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" },
373- for (const file of ["config-core.js", "config-page.js"]) {441+ { serviceId: "svc-b", stopLabel: "République", lineLabel: "96", destinationLabel: "Porte des Lilas", lineMode: "BUS", lineColor: "#007852", lineTextColor: "#ffffff" },
374- const source = readFileSync(join(here, "../src", file), "utf8");442+ { serviceId: "svc-c", stopLabel: "Nation", lineLabel: "A", destinationLabel: "Cergy", lineMode: "RER", lineColor: "#e3051c", lineTextColor: "#ffffff" },
375- assert.doesNotMatch(source, forbidden);443+ ];
444+ let state = initialConfigState(parseConfigFragment(fragmentWith([])));
445+ services.forEach((service, index) => {
446+ const entry = favoriteFromService(`cfg-${index}`, service, index);
447+ assert.ok(entry);
448+ state = reduceConfigState(state, { type: "favorite-add", favorite: entry });
449+ });
450+ assert.deepEqual(state.favorites.map((entry) => entry.serviceId), ["svc-a", "svc-b", "svc-c"]);
451+ assert.deepEqual(
452+ state.favorites.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
453+ services.map(({ lineMode, lineColor, lineTextColor }) => ({ lineMode, lineColor, lineTextColor })),
454+ );
455+ state = reduceConfigState(state, { type: "favorite-rename", id: "cfg-1", displayName: "Travail" });
456+ state = reduceConfigState(state, { type: "favorite-move", id: "cfg-2", delta: -2 });
457+ state = reduceConfigState(state, { type: "favorite-remove", id: "cfg-0" });
458+ assert.deepEqual(state.favorites.map((entry) => [entry.id, entry.sortOrder, entry.displayName]), [
459+ ["cfg-2", 0, undefined],
460+ ["cfg-1", 1, "Travail"],
461+ ]);
462+ const outcome = planConfigResult(state);
463+ assert.equal(outcome.ok, true);
464+ if (outcome.ok) assert.equal(outcome.payload.favorites.every(isFavorite), true);
465+});
466+
467+test("catalog response guards reject identifiers and malformed or oversized collections", () => {
468+ const place = { placeId: "plc-a", stopLabel: "Châtelet", localityLabel: "Paris", mode: "METRO" };
469+ const service = { serviceId: "svc-a", stopLabel: "Châtelet", lineLabel: "4", destinationLabel: "Bagneux", lineMode: "METRO", lineColor: "#be418d", lineTextColor: "#ffffff" };
470+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [place] }), true);
471+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: Array(21).fill(place) }), false);
472+ assert.equal(isPlaceSearchResult({ schemaVersion: 1, places: [{ ...place, monitoringRef: "raw" }] }), false);
473+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [service] }, "plc-a"), true);
474+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "other", services: [service] }, "plc-a"), false);
475+ assert.equal(favoriteFromService("cfg-a", { ...service, lineRef: "raw" }, 0), null);
476+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineColor: "#BE418D" }] }, "plc-a"), false);
477+ assert.equal(isServiceOptionsResult({ schemaVersion: 1, placeId: "plc-a", services: [{ ...service, lineMode: "metro" }] }, "plc-a"), false);
478+ const { lineTextColor: _missingTextColor, ...missingTextColor } = service;
479+ assert.equal(favoriteFromService("cfg-a", missingTextColor, 0), null);
480+});
481+
482+test("favorite preview uses the credential-free recorded departure fixture", () => {
483+ const recorded = JSON.parse(
484+ readFileSync(join(here, "../../../fixtures/departures/foundation.json"), "utf8"),
485+ );
486+ assert.deepEqual(
487+ RECORDED_PREVIEW,
488+ recorded.result.departures.map(({ minutes, status }: { minutes: number; status: string }) => ({ minutes, status })),
489+ );
490+ assert.equal(JSON.stringify(RECORDED_PREVIEW).includes(recorded.favorite.serviceId), false);
491+});
492+test("catalog search waits 300 ms, cancels superseded work, and validates responses", async () => {
493+ const timers = new Map<number, () => void>();
494+ const delays: number[] = [];
495+ let nextTimer = 0;
496+ const requests: string[] = [];
497+ const client = createCatalogClient({
498+ setTimer(callback: () => void, delay: number) {
499+ const id = ++nextTimer;
500+
501+ delays.push(delay);
502+ timers.set(id, callback);
503+ return id;
504+ },
505+ clearTimer(id: number) {
506+ timers.delete(id);
507+ },
508+ async fetchImpl(url: string) {
509+ requests.push(url);
510+ return {
511+ ok: true,
512+ async json() {
513+ return { schemaVersion: 1, places: [] };
514+ },
515+ };
516+ },
517+ });
518+ const superseded = client.searchPlaces("ch");
519+ const latest = client.searchPlaces("cha");
520+ await assert.rejects(superseded, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
521+ assert.deepEqual(delays, [SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS]);
522+ assert.equal(timers.size, 1);
523+ [...timers.values()][0]!();
524+ assert.deepEqual(await latest, []);
525+ assert.deepEqual(requests, ["/api/catalog/places?q=cha"]);
526+
527+ const invalid = createCatalogClient({
528+ setTimer(callback: () => void) {
529+ queueMicrotask(callback);
530+ return 1;
531+ },
532+ clearTimer() {},
533+ async fetchImpl() {
534+ return { ok: true, async json() { return { schemaVersion: 1, places: [{ placeId: "leak" }] }; } };
535+ },
536+ });
537+ await assert.rejects(invalid.searchPlaces("invalid"), (error: unknown) =>
538+ error instanceof CatalogClientError && error.code === "BACKEND_UNAVAILABLE");
539+});
540+
541+test("page keeps secrets out of durable and observable surfaces", () => {
542+ const core = readFileSync(join(here, "../src/config-core.js"), "utf8");
543+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
544+ const client = readFileSync(join(here, "../src/catalog-client.js"), "utf8");
545+ const html = readFileSync(join(here, "../index.html"), "utf8");
546+ for (const source of [core, controller, client]) {
547+ assert.doesNotMatch(source, /localStorage|sessionStorage|document\\.cookie|WebSocket|console\\./u);
548+ }
549+ assert.doesNotMatch(client, /prim\\.iledefrance-mobilites/u);
550+ assert.match(html, /type="password" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"/u);
551+ assert.match(html, /connect-src 'self'/u);
552+ assert.doesNotMatch(html, /value=["'][^"']+["']/u);
553+ assert.match(html, /img-src 'self'/u);
554+ assert.doesNotMatch(html, /img-src 'none'/u);
555+});
556+
557+test("bilingual About copy exposes the required legal, privacy, attribution, and safe-link facts", () => {
558+ const html = readFileSync(join(here, "../index.html"), "utf8");
559+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
560+ for (const fact of [
561+ "Version 0.1.0",
562+ "Publication director:",
563+ "Directeur de la publication :",
564+ "Anthodev",
565+ "88 Colin P. Kelly Jr. St.",
566+ "localStorage",
567+ "no analytics or telemetry",
568+ "aucun cookie",
569+ "Référentiel des lignes",
570+ "conditions d’utilisation IDFM/PRIM",
571+ "search terms are sent",
572+ "termes saisis sont transmis",
573+ "ODbL 1.0",
574+ "github.com/Anthodev/lapin-fute",
575+ ]) assert.equal(html.includes(fact), true, fact);
576+ assert.doesNotMatch(html, /software licen[cs]e|licence du logiciel/iu);
577+ assert.equal((html.match(/target="_blank"/gu) ?? []).length, (html.match(/rel="noopener noreferrer"/gu) ?? []).length);
578+ assert.match(controller, /lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u);
579+ assert.match(controller, /image\.addEventListener\("error"/u);
580+ assert.match(controller, /elements\.config_view\.hidden = true/u);
581+ assert.match(controller, /aboutReturnFocus\.focus\(\)/u);
582+});
583+
584+test("official rail badges cover every RER and Transilien line", () => {
585+ for (const line of ["A", "B", "C", "D", "E"]) {
586+ assert.match(lineBadgeAssetUrl("RER", line) ?? "", new RegExp(`/rer-${line.toLowerCase()}\\.png$`, "u"));
587+ }
588+ for (const line of ["H", "J", "K", "L", "N", "P", "R", "U", "V"]) {
589+ assert.match(
590+ lineBadgeAssetUrl("TRANSILIEN", line) ?? "",
591+ new RegExp(`/transilien-${line.toLowerCase()}\\.png$`, "u"),
592+ );
376 }593 }
377 });594 });
595+
596+test("legacy favorites resolve no official image and use an accessible neutral fallback", () => {
597+ const legacy = legacyFavorite("legacy-badge", 0);
598+ const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
599+
600+ assert.equal(lineBadgeAssetUrl(legacy.lineMode, legacy.lineLabel), undefined);
601+ assert.match(
602+ controller,
603+ /const assetUrl = service\.lineMode === undefined\s+\? undefined\s+:\s+lineBadgeAssetUrl\(service\.lineMode, service\.lineLabel\)/u,
604+ );
605+ assert.match(controller, /const NEUTRAL_LINE_BACKGROUND = "#52616f"/u);
606+ assert.match(controller, /const NEUTRAL_LINE_TEXT = "#ffffff"/u);
607+ assert.match(controller, /const backgroundColor = service\.lineColor \?\? NEUTRAL_LINE_BACKGROUND/u);
608+ assert.match(controller, /const textColor = service\.lineTextColor \?\? NEUTRAL_LINE_TEXT/u);
609+ assert.match(controller, /svg\.setAttribute\("role", "img"\)/u);
610+ assert.match(controller, /svg\.setAttribute\("aria-label", service\.lineLabel\)/u);
611+});
612+
613+test("the first-load gzip metric includes the eagerly imported badge resolver", () => {
614+ const measurement = readFileSync(join(here, "../../../scripts/measure-config-page.mjs"), "utf8");
615+ assert.match(measurement, /packages\/config-page\/src\/line-badge-assets\.js/u);
616+});
modified packages/contracts/src/index.ts +35 -5
@@ -109,7 +109,7 @@ export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
109109 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
110110 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
111111
112-export interface Favorite {
112+export type Favorite = {
113113 schemaVersion: typeof SCHEMA_VERSION;
114114 id: string;
115115 serviceId: string;
@@ -118,7 +118,18 @@ export interface Favorite {
118118 lineLabel: string;
119119 destinationLabel: string;
120120 sortOrder: number;
121-}
121+} & (
122+ | {
123+ lineMode: TransportMode;
124+ lineColor: string;
125+ lineTextColor: string;
126+ }
127+ | {
128+ lineMode?: never;
129+ lineColor?: never;
130+ lineTextColor?: never;
131+ }
132+);
122133
123134 export interface Departure {
124135 expectedAt: number;
@@ -163,6 +174,9 @@ export interface ServiceOption {
163174 stopLabel: string;
164175 lineLabel: string;
165176 destinationLabel: string;
177+ lineMode: TransportMode;
178+ lineColor: string;
179+ lineTextColor: string;
166180 }
167181
168182 export interface ServiceOptionsResult {
@@ -187,14 +201,14 @@ export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>;
187201 const encoder = new TextEncoder();
188202 const UINT32_MAX = 0xffff_ffff;
189203 const domainKeys = {
190- favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"],
204+ favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "sortOrder"],
191205 departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"],
192206 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
193207 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
194208 keyUpdate: ["schemaVersion", "action", "value"],
195209 placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],
196210 placeSearchResult: ["schemaVersion", "places"],
197- serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"],
211+ serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"],
198212 serviceOptionsResult: ["schemaVersion", "placeId", "services"],
199213 catalogError: ["schemaVersion", "code"],
200214 } as const;
@@ -215,6 +229,10 @@ function boundedString(value: unknown, maximum: number): value is string {
215229 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
216230 }
217231
232+export function isLineColor(value: unknown): value is string {
233+ return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value);
234+}
235+
218236 function uint32(value: unknown): value is number {
219237 return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX;
220238 }
@@ -229,6 +247,10 @@ function optionalBoundedString(value: unknown, maximum: number): boolean {
229247
230248 export function isFavorite(value: unknown): value is Favorite {
231249 if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false;
250+ const hasLineMode = Object.hasOwn(value, "lineMode");
251+ const hasLineColor = Object.hasOwn(value, "lineColor");
252+ const hasLineTextColor = Object.hasOwn(value, "lineTextColor");
253+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
232254 return value.schemaVersion === SCHEMA_VERSION
233255 && boundedString(value.id, LIMITS.idUtf8Bytes)
234256 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
@@ -236,6 +258,11 @@ export function isFavorite(value: unknown): value is Favorite {
236258 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
237259 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
238260 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
261+ && (!hasLineMode || (
262+ TRANSPORT_MODE.includes(value.lineMode as TransportMode)
263+ && isLineColor(value.lineColor)
264+ && isLineColor(value.lineTextColor)
265+ ))
239266 && Number.isInteger(value.sortOrder)
240267 && Number(value.sortOrder) >= 0
241268 && Number(value.sortOrder) < LIMITS.favorites;
@@ -297,7 +324,10 @@ export function isServiceOption(value: unknown): value is ServiceOption {
297324 return boundedString(value.serviceId, LIMITS.idUtf8Bytes)
298325 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
299326 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
300- && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes);
327+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
328+ && TRANSPORT_MODE.includes(value.lineMode as TransportMode)
329+ && isLineColor(value.lineColor)
330+ && isLineColor(value.lineTextColor);
301331 }
302332
303333 export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult {
@@ -109,7 +109,7 @@ export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
109 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];109 export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
110 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];110 export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
111 111
112-export interface Favorite {112+export type Favorite = {
113 schemaVersion: typeof SCHEMA_VERSION;113 schemaVersion: typeof SCHEMA_VERSION;
114 id: string;114 id: string;
115 serviceId: string;115 serviceId: string;
@@ -118,7 +118,18 @@ export interface Favorite {
118 lineLabel: string;118 lineLabel: string;
119 destinationLabel: string;119 destinationLabel: string;
120 sortOrder: number;120 sortOrder: number;
121-}121+} & (
122+ | {
123+ lineMode: TransportMode;
124+ lineColor: string;
125+ lineTextColor: string;
126+ }
127+ | {
128+ lineMode?: never;
129+ lineColor?: never;
130+ lineTextColor?: never;
131+ }
132+);
122 133
123 export interface Departure {134 export interface Departure {
124 expectedAt: number;135 expectedAt: number;
@@ -163,6 +174,9 @@ export interface ServiceOption {
163 stopLabel: string;174 stopLabel: string;
164 lineLabel: string;175 lineLabel: string;
165 destinationLabel: string;176 destinationLabel: string;
177+ lineMode: TransportMode;
178+ lineColor: string;
179+ lineTextColor: string;
166 }180 }
167 181
168 export interface ServiceOptionsResult {182 export interface ServiceOptionsResult {
@@ -187,14 +201,14 @@ export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>;
187 const encoder = new TextEncoder();201 const encoder = new TextEncoder();
188 const UINT32_MAX = 0xffff_ffff;202 const UINT32_MAX = 0xffff_ffff;
189 const domainKeys = {203 const domainKeys = {
190- favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"],204+ favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor", "sortOrder"],
191 departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"],205 departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"],
192 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],206 result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
193 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],207 error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
194 keyUpdate: ["schemaVersion", "action", "value"],208 keyUpdate: ["schemaVersion", "action", "value"],
195 placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],209 placeSearchItem: ["placeId", "stopLabel", "localityLabel", "mode"],
196 placeSearchResult: ["schemaVersion", "places"],210 placeSearchResult: ["schemaVersion", "places"],
197- serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel"],211+ serviceOption: ["serviceId", "stopLabel", "lineLabel", "destinationLabel", "lineMode", "lineColor", "lineTextColor"],
198 serviceOptionsResult: ["schemaVersion", "placeId", "services"],212 serviceOptionsResult: ["schemaVersion", "placeId", "services"],
199 catalogError: ["schemaVersion", "code"],213 catalogError: ["schemaVersion", "code"],
200 } as const;214 } as const;
@@ -215,6 +229,10 @@ function boundedString(value: unknown, maximum: number): value is string {
215 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;229 return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
216 }230 }
217 231
232+export function isLineColor(value: unknown): value is string {
233+ return typeof value === "string" && /^#[0-9a-f]{6}$/u.test(value);
234+}
235+
218 function uint32(value: unknown): value is number {236 function uint32(value: unknown): value is number {
219 return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX;237 return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX;
220 }238 }
@@ -229,6 +247,10 @@ function optionalBoundedString(value: unknown, maximum: number): boolean {
229 247
230 export function isFavorite(value: unknown): value is Favorite {248 export function isFavorite(value: unknown): value is Favorite {
231 if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false;249 if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false;
250+ const hasLineMode = Object.hasOwn(value, "lineMode");
251+ const hasLineColor = Object.hasOwn(value, "lineColor");
252+ const hasLineTextColor = Object.hasOwn(value, "lineTextColor");
253+ if (hasLineMode !== hasLineColor || hasLineMode !== hasLineTextColor) return false;
232 return value.schemaVersion === SCHEMA_VERSION254 return value.schemaVersion === SCHEMA_VERSION
233 && boundedString(value.id, LIMITS.idUtf8Bytes)255 && boundedString(value.id, LIMITS.idUtf8Bytes)
234 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)256 && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
@@ -236,6 +258,11 @@ export function isFavorite(value: unknown): value is Favorite {
236 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)258 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
237 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)259 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
238 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)260 && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
261+ && (!hasLineMode || (
262+ TRANSPORT_MODE.includes(value.lineMode as TransportMode)
263+ && isLineColor(value.lineColor)
264+ && isLineColor(value.lineTextColor)
265+ ))
239 && Number.isInteger(value.sortOrder)266 && Number.isInteger(value.sortOrder)
240 && Number(value.sortOrder) >= 0267 && Number(value.sortOrder) >= 0
241 && Number(value.sortOrder) < LIMITS.favorites;268 && Number(value.sortOrder) < LIMITS.favorites;
@@ -297,7 +324,10 @@ export function isServiceOption(value: unknown): value is ServiceOption {
297 return boundedString(value.serviceId, LIMITS.idUtf8Bytes)324 return boundedString(value.serviceId, LIMITS.idUtf8Bytes)
298 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)325 && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
299 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)326 && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
300- && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes);327+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
328+ && TRANSPORT_MODE.includes(value.lineMode as TransportMode)
329+ && isLineColor(value.lineColor)
330+ && isLineColor(value.lineTextColor);
301 }331 }
302 332
303 export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult {333 export function isServiceOptionsResult(value: unknown): value is ServiceOptionsResult {
modified packages/contracts/test/contracts.test.ts +32 -0
@@ -41,6 +41,9 @@ const favorite = {
4141 stopLabel: "Châtelet",
4242 lineLabel: "Métro 1",
4343 destinationLabel: "La Défense",
44+ lineMode: "METRO",
45+ lineColor: "#ffbe00",
46+ lineTextColor: "#000000",
4447 sortOrder: 0,
4548 };
4649
@@ -92,6 +95,25 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
9295 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
9396 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
9497 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99+ assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100+ assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
101+ const {
102+ lineMode: _legacyLineMode,
103+ lineColor: _legacyLineColor,
104+ lineTextColor: _legacyLineTextColor,
105+ ...legacyFavorite
106+ } = favorite;
107+ assert.equal(isFavorite(legacyFavorite), true);
108+ assert.equal(isFavorite({ ...legacyFavorite, lineMode: favorite.lineMode }), false);
109+ assert.equal(isFavorite({
110+ ...legacyFavorite,
111+ lineMode: favorite.lineMode,
112+ lineColor: favorite.lineColor,
113+ }), false);
114+ assert.equal(isFavorite({ ...legacyFavorite, lineMode: undefined }), false);
115+ const { lineColor: _favoriteLineColor, ...favoriteWithoutColor } = favorite;
116+ assert.equal(isFavorite(favoriteWithoutColor), false);
95117 assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes);
96118 assert.equal(isDepartureResult({
97119 ...result,
@@ -259,6 +281,9 @@ test("catalog validators enforce exact public fields, bounds, and no raw source
259281 stopLabel: "Châtelet",
260282 lineLabel: "Métro 1",
261283 destinationLabel: "La Défense",
284+ lineMode: "METRO",
285+ lineColor: "#ffbe00",
286+ lineTextColor: "#000000",
262287 };
263288 const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode };
264289
@@ -297,6 +322,13 @@ test("catalog validators enforce exact public fields, bounds, and no raw source
297322
298323 assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true);
299324 assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false);
325+ assert.equal(isServiceOption({ ...serviceOption, lineMode: "metro" }), false);
326+ assert.equal(isServiceOption({ ...serviceOption, lineMode: "TER" }), false);
327+ assert.equal(isServiceOption({ ...serviceOption, lineColor: "#FFBE00" }), false);
328+ assert.equal(isServiceOption({ ...serviceOption, lineColor: "#12345" }), false);
329+ assert.equal(isServiceOption({ ...serviceOption, lineTextColor: "transparent" }), false);
330+ const { lineTextColor: _lineTextColor, ...serviceWithoutTextColor } = serviceOption;
331+ assert.equal(isServiceOption(serviceWithoutTextColor), false);
300332 assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false);
301333 assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false);
302334 assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false);
@@ -41,6 +41,9 @@ const favorite = {
41 stopLabel: "Châtelet",41 stopLabel: "Châtelet",
42 lineLabel: "Métro 1",42 lineLabel: "Métro 1",
43 destinationLabel: "La Défense",43 destinationLabel: "La Défense",
44+ lineMode: "METRO",
45+ lineColor: "#ffbe00",
46+ lineTextColor: "#000000",
44 sortOrder: 0,47 sortOrder: 0,
45 };48 };
46 49
@@ -92,6 +95,25 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
92 assert.equal(isFavorite({ ...favorite, unknown: true }), false);95 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
93 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);96 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
94 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);97 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99+ assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100+ assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
101+ const {
102+ lineMode: _legacyLineMode,
103+ lineColor: _legacyLineColor,
104+ lineTextColor: _legacyLineTextColor,
105+ ...legacyFavorite
106+ } = favorite;
107+ assert.equal(isFavorite(legacyFavorite), true);
108+ assert.equal(isFavorite({ ...legacyFavorite, lineMode: favorite.lineMode }), false);
109+ assert.equal(isFavorite({
110+ ...legacyFavorite,
111+ lineMode: favorite.lineMode,
112+ lineColor: favorite.lineColor,
113+ }), false);
114+ assert.equal(isFavorite({ ...legacyFavorite, lineMode: undefined }), false);
115+ const { lineColor: _favoriteLineColor, ...favoriteWithoutColor } = favorite;
116+ assert.equal(isFavorite(favoriteWithoutColor), false);
95 assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes);117 assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes);
96 assert.equal(isDepartureResult({118 assert.equal(isDepartureResult({
97 ...result,119 ...result,
@@ -259,6 +281,9 @@ test("catalog validators enforce exact public fields, bounds, and no raw source
259 stopLabel: "Châtelet",281 stopLabel: "Châtelet",
260 lineLabel: "Métro 1",282 lineLabel: "Métro 1",
261 destinationLabel: "La Défense",283 destinationLabel: "La Défense",
284+ lineMode: "METRO",
285+ lineColor: "#ffbe00",
286+ lineTextColor: "#000000",
262 };287 };
263 const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode };288 const requiredPlace = { placeId: placeItem.placeId, stopLabel: placeItem.stopLabel, mode: placeItem.mode };
264 289
@@ -297,6 +322,13 @@ test("catalog validators enforce exact public fields, bounds, and no raw source
297 322
298 assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true);323 assert.equal(isServiceOption({ ...serviceOption, serviceId: opaque64 }), true);
299 assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false);324 assert.equal(isServiceOption({ ...serviceOption, destinationLabel: "" }), false);
325+ assert.equal(isServiceOption({ ...serviceOption, lineMode: "metro" }), false);
326+ assert.equal(isServiceOption({ ...serviceOption, lineMode: "TER" }), false);
327+ assert.equal(isServiceOption({ ...serviceOption, lineColor: "#FFBE00" }), false);
328+ assert.equal(isServiceOption({ ...serviceOption, lineColor: "#12345" }), false);
329+ assert.equal(isServiceOption({ ...serviceOption, lineTextColor: "transparent" }), false);
330+ const { lineTextColor: _lineTextColor, ...serviceWithoutTextColor } = serviceOption;
331+ assert.equal(isServiceOption(serviceWithoutTextColor), false);
300 assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false);332 assert.equal(isServiceOption({ ...serviceOption, directionId: 1 }), false);
301 assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false);333 assert.equal(isServiceOption({ ...serviceOption, destinationRef: "Q-1" }), false);
302 assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false);334 assert.equal(isServiceOption({ serviceId: opaque64, stopLabel: "Châtelet", lineLabel: "1" }), false);
modified packages/watch/.lock-waf_linux_build +1 -1
@@ -1,6 +1,6 @@
11 argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
22 config_cmd = 'configure'
3-environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
3+environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2937,unix/unix:/tmp/.ICE-unix/2937', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_aLtUGr', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '14866', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_qgtLXn', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2302', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'HERDR_STARTUP_CWD': '/home/anthodev', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a05e273bfb7d2196e6386c6401cc46', 'MANAGERPIDFDID': '393', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
44 files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
55 hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
66 launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
@@ -1,6 +1,6 @@
1 argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']1 argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
2 config_cmd = 'configure'2 config_cmd = 'configure'
3-environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}3+environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2937,unix/unix:/tmp/.ICE-unix/2937', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_aLtUGr', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '14866', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_qgtLXn', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2302', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'HERDR_STARTUP_CWD': '/home/anthodev', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a05e273bfb7d2196e6386c6401cc46', 'MANAGERPIDFDID': '393', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
4 files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']4 files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
5 hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'5 hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
6 launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'6 launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
added scripts/measure-config-page.mjs +25 -0
new file mode 100644
@@ -0,0 +1,25 @@
1+import { gzipSync } from "node:zlib";
2+import { readFileSync } from "node:fs";
3+import { fileURLToPath } from "node:url";
4+
5+const BUDGET_BYTES = 200 * 1024;
6+const assets = [
7+ "../packages/config-page/src/config-core.js",
8+ "../packages/config-page/src/catalog-client.js",
9+ "../packages/config-page/src/preview-fixture.js",
10+ "../packages/config-page/src/line-badge-assets.js",
11+ "../packages/config-page/src/config-page.js",
12+ "../packages/config-page/styles/config-page.css",
13+];
14+
15+const measurements = assets.map((relativePath) => {
16+ const path = fileURLToPath(new URL(relativePath, import.meta.url));
17+ return { relativePath, bytes: gzipSync(readFileSync(path), { level: 9 }).byteLength };
18+});
19+const totalBytes = measurements.reduce((total, entry) => total + entry.bytes, 0);
20+
21+for (const entry of measurements) {
22+ process.stdout.write(`${entry.relativePath}: ${entry.bytes} gzip bytes\n`);
23+}
24+process.stdout.write(`config page JS+CSS: ${totalBytes} / ${BUDGET_BYTES} gzip bytes\n`);
25+if (totalBytes > BUDGET_BYTES) process.exitCode = 1;
new file mode 100644
@@ -0,0 +1,25 @@
1+import { gzipSync } from "node:zlib";
2+import { readFileSync } from "node:fs";
3+import { fileURLToPath } from "node:url";
4+
5+const BUDGET_BYTES = 200 * 1024;
6+const assets = [
7+ "../packages/config-page/src/config-core.js",
8+ "../packages/config-page/src/catalog-client.js",
9+ "../packages/config-page/src/preview-fixture.js",
10+ "../packages/config-page/src/line-badge-assets.js",
11+ "../packages/config-page/src/config-page.js",
12+ "../packages/config-page/styles/config-page.css",
13+];
14+
15+const measurements = assets.map((relativePath) => {
16+ const path = fileURLToPath(new URL(relativePath, import.meta.url));
17+ return { relativePath, bytes: gzipSync(readFileSync(path), { level: 9 }).byteLength };
18+});
19+const totalBytes = measurements.reduce((total, entry) => total + entry.bytes, 0);
20+
21+for (const entry of measurements) {
22+ process.stdout.write(`${entry.relativePath}: ${entry.bytes} gzip bytes\n`);
23+}
24+process.stdout.write(`config page JS+CSS: ${totalBytes} / ${BUDGET_BYTES} gzip bytes\n`);
25+if (totalBytes > BUDGET_BYTES) process.exitCode = 1;
added scripts/sync-line-badges.mjs +173 -0
new file mode 100644
@@ -0,0 +1,173 @@
1+import { createHash } from "node:crypto";
2+import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3+import { dirname, join, resolve } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const ASSET_ROOT = join(ROOT, "packages/config-page/assets");
8+const OUTPUT = join(ASSET_ROOT, "line-badges");
9+const RESOLVER = join(ROOT, "packages/config-page/src/line-badge-assets.js");
10+const ORIGIN = "https://data.iledefrance-mobilites.fr";
11+const DATASET = "referentiel-des-lignes";
12+const DATASET_URL = `${ORIGIN}/explore/dataset/${DATASET}/`;
13+const DATASET_LICENSE_URL = "https://opendatacommons.org/licenses/odbl/1-0/";
14+const ILICO_TERMS_URL = "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation";
15+const TRUSTED_SVG_SHA256 = new Set([
16+ "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25",
17+]);
18+const MAX_BODY_BYTES = 2 * 1024 * 1024;
19+const TIMEOUT_MS = 15_000;
20+
21+function fail(message) {
22+ throw new Error(message);
23+}
24+
25+function safeLabel(value) {
26+ if (typeof value !== "string" || value.trim() === "") fail("Line label is missing");
27+ return value.trim();
28+}
29+
30+function modeFor(record) {
31+ const mode = String(record.transportmode ?? "").trim().toLowerCase();
32+ const submode = String(record.transportsubmode ?? "").trim().toLowerCase();
33+ if (mode === "metro") return "METRO";
34+ if (mode === "tram") return "TRAM";
35+ if (mode === "rail" && submode === "local") return "RER";
36+ if (mode === "rail" && submode === "suburbanrailway") return "TRANSILIEN";
37+ return undefined;
38+}
39+
40+function slug(value) {
41+ const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase();
42+ const result = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "");
43+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62})$/u.test(result)) fail(`Unsafe generated label: ${value}`);
44+ return result;
45+}
46+
47+async function boundedFetch(url, expectedType) {
48+ const target = new URL(url);
49+ if (target.origin !== ORIGIN) fail(`Unexpected asset origin: ${target.origin}`);
50+ if (!target.pathname.startsWith(`/api/explore/v2.1/catalog/datasets/${DATASET}/`)) {
51+ fail(`Unexpected asset path: ${target.pathname}`);
52+ }
53+ const controller = new AbortController();
54+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
55+ try {
56+ const response = await fetch(target, {
57+ redirect: "manual",
58+ credentials: "omit",
59+ cache: "no-store",
60+ headers: { accept: expectedType },
61+ signal: controller.signal,
62+ });
63+ if (response.status !== 200 || response.body === null) fail(`${target.pathname} returned HTTP ${response.status}`);
64+ const chunks = [];
65+ let length = 0;
66+ for await (const chunk of response.body) {
67+ length += chunk.byteLength;
68+ if (length > MAX_BODY_BYTES) fail(`${target.pathname} exceeds 2 MiB`);
69+ chunks.push(chunk);
70+ }
71+ return { body: Buffer.concat(chunks), type: (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase() };
72+ } finally {
73+ clearTimeout(timeout);
74+ }
75+}
76+
77+function validateImage(body, type, sha256) {
78+ if (type === "image/png") {
79+ if (body.length < 8 || !body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) fail("Invalid PNG signature");
80+ return "png";
81+ }
82+ if (type !== "image/svg+xml") fail(`Unsupported badge media type: ${type}`);
83+ if (!TRUSTED_SVG_SHA256.has(sha256)) {
84+ fail(`Unreviewed SVG badge ${sha256}; review and pin it before publication`);
85+ }
86+ const source = new TextDecoder("utf-8", { fatal: true }).decode(body);
87+ if (!/<svg\b/iu.test(source)
88+ || /<\s*(?:script|foreignObject|iframe|object|embed)\b/iu.test(source)
89+ || /\son[a-z]+\s*=/iu.test(source)
90+ || /(?:href|src)\s*=\s*["']\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)
91+ || /url\(\s*["']?\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)) {
92+ fail("Unsafe pinned SVG badge");
93+ }
94+ return "svg";
95+}
96+
97+async function records() {
98+ const query = new URL(`/api/explore/v2.1/catalog/datasets/${DATASET}/records`, ORIGIN);
99+ query.searchParams.set("select", "name_line,shortname_line,transportmode,transportsubmode,status,picto");
100+ query.searchParams.set("where", "status = 'active' and (transportmode in ('metro','tram') or (transportmode = 'rail' and transportsubmode in ('local','suburbanRailway')))");
101+ query.searchParams.set("limit", "100");
102+ const { body, type } = await boundedFetch(query, "application/json");
103+ if (type !== "application/json") fail(`Unexpected records media type: ${type}`);
104+ const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
105+ if (!Number.isSafeInteger(payload.total_count) || !Array.isArray(payload.results) || payload.results.length !== payload.total_count) {
106+ fail("Incomplete line badge result set");
107+ }
108+ return payload.results;
109+}
110+
111+async function main() {
112+ const selected = (await records()).map((record) => {
113+ const mode = modeFor(record);
114+ const label = safeLabel(record.shortname_line || record.name_line);
115+ const picto = record.picto;
116+ if (mode === undefined || picto === null || typeof picto !== "object" || typeof picto.url !== "string") return undefined;
117+ return { key: `${mode}:${label}`, mode, label, url: picto.url };
118+ }).filter(Boolean).sort((a, b) => a.key.localeCompare(b.key, "en"));
119+
120+ const keys = new Set();
121+ for (const item of selected) {
122+ if (keys.has(item.key)) fail(`Duplicate public badge key: ${item.key}`);
123+ keys.add(item.key);
124+ }
125+
126+ await mkdir(ASSET_ROOT, { recursive: true });
127+ const staging = `${OUTPUT}.tmp-${process.pid}`;
128+ const backup = `${OUTPUT}.bak-${process.pid}`;
129+ await rm(staging, { recursive: true, force: true });
130+ await mkdir(staging);
131+ const manifest = [];
132+ const resolverEntries = [];
133+ try {
134+ for (const item of selected) {
135+ const { body, type } = await boundedFetch(item.url, "image/png,image/svg+xml");
136+ const sha256 = createHash("sha256").update(body).digest("hex");
137+ const extension = validateImage(body, type, sha256);
138+ const filename = `${item.mode.toLowerCase()}-${slug(item.label)}.${extension}`;
139+ await writeFile(join(staging, filename), body, { flag: "wx" });
140+ manifest.push({ key: item.key, file: filename, mediaType: type, bytes: body.length, sha256, source: item.url });
141+ resolverEntries.push([item.key, filename]);
142+ }
143+
144+ const retrievedAt = new Date().toISOString();
145+ const attribution = {
146+ publisher: "Île-de-France Mobilités",
147+ dataset: DATASET,
148+ datasetUrl: DATASET_URL,
149+ datasetLicense: "ODbL 1.0",
150+ datasetLicenseUrl: DATASET_LICENSE_URL,
151+ pictogramReuseTermsUrl: ILICO_TERMS_URL,
152+ retrievedAt,
153+ assets: manifest,
154+ };
155+ await writeFile(join(staging, "attribution.json"), `${JSON.stringify(attribution, null, 2)}\n`);
156+ const resolver = `// Generated by scripts/sync-line-badges.mjs. Do not edit.\nconst ASSETS = new Map(${JSON.stringify(resolverEntries, null, 2)});\n\nexport function lineBadgeAssetUrl(lineMode, lineLabel) {\n const file = ASSETS.get(\`${"${lineMode}:${lineLabel}"}\`);\n return file === undefined ? undefined : new URL(\`../assets/line-badges/${"${file}"}\`, import.meta.url).href;\n}\n`;
157+ const resolverTemp = `${RESOLVER}.tmp-${process.pid}`;
158+ await writeFile(resolverTemp, resolver);
159+ await rm(backup, { recursive: true, force: true });
160+ try { await rename(OUTPUT, backup); } catch (error) { if (error?.code !== "ENOENT") throw error; }
161+ await rename(staging, OUTPUT);
162+ await rename(resolverTemp, RESOLVER);
163+ await rm(backup, { recursive: true, force: true });
164+ const bytes = manifest.reduce((sum, asset) => sum + asset.bytes, 0);
165+ process.stdout.write(`${manifest.length} official badges synchronized (${bytes} bytes)\n`);
166+ } catch (error) {
167+ await rm(staging, { recursive: true, force: true });
168+ try { await rename(backup, OUTPUT); } catch (restoreError) { if (restoreError?.code !== "ENOENT") throw restoreError; }
169+ throw error;
170+ }
171+}
172+
173+await main();
new file mode 100644
@@ -0,0 +1,173 @@
1+import { createHash } from "node:crypto";
2+import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3+import { dirname, join, resolve } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const ASSET_ROOT = join(ROOT, "packages/config-page/assets");
8+const OUTPUT = join(ASSET_ROOT, "line-badges");
9+const RESOLVER = join(ROOT, "packages/config-page/src/line-badge-assets.js");
10+const ORIGIN = "https://data.iledefrance-mobilites.fr";
11+const DATASET = "referentiel-des-lignes";
12+const DATASET_URL = `${ORIGIN}/explore/dataset/${DATASET}/`;
13+const DATASET_LICENSE_URL = "https://opendatacommons.org/licenses/odbl/1-0/";
14+const ILICO_TERMS_URL = "https://prim.iledefrance-mobilites.fr/fr/conditions-utilisation";
15+const TRUSTED_SVG_SHA256 = new Set([
16+ "7c79d62ae30950fe638667f63b0ec62bec9bc18a171b1309ca996e670d3d9e25",
17+]);
18+const MAX_BODY_BYTES = 2 * 1024 * 1024;
19+const TIMEOUT_MS = 15_000;
20+
21+function fail(message) {
22+ throw new Error(message);
23+}
24+
25+function safeLabel(value) {
26+ if (typeof value !== "string" || value.trim() === "") fail("Line label is missing");
27+ return value.trim();
28+}
29+
30+function modeFor(record) {
31+ const mode = String(record.transportmode ?? "").trim().toLowerCase();
32+ const submode = String(record.transportsubmode ?? "").trim().toLowerCase();
33+ if (mode === "metro") return "METRO";
34+ if (mode === "tram") return "TRAM";
35+ if (mode === "rail" && submode === "local") return "RER";
36+ if (mode === "rail" && submode === "suburbanrailway") return "TRANSILIEN";
37+ return undefined;
38+}
39+
40+function slug(value) {
41+ const normalized = value.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase();
42+ const result = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "");
43+ if (!/^[a-z0-9](?:[a-z0-9-]{0,62})$/u.test(result)) fail(`Unsafe generated label: ${value}`);
44+ return result;
45+}
46+
47+async function boundedFetch(url, expectedType) {
48+ const target = new URL(url);
49+ if (target.origin !== ORIGIN) fail(`Unexpected asset origin: ${target.origin}`);
50+ if (!target.pathname.startsWith(`/api/explore/v2.1/catalog/datasets/${DATASET}/`)) {
51+ fail(`Unexpected asset path: ${target.pathname}`);
52+ }
53+ const controller = new AbortController();
54+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
55+ try {
56+ const response = await fetch(target, {
57+ redirect: "manual",
58+ credentials: "omit",
59+ cache: "no-store",
60+ headers: { accept: expectedType },
61+ signal: controller.signal,
62+ });
63+ if (response.status !== 200 || response.body === null) fail(`${target.pathname} returned HTTP ${response.status}`);
64+ const chunks = [];
65+ let length = 0;
66+ for await (const chunk of response.body) {
67+ length += chunk.byteLength;
68+ if (length > MAX_BODY_BYTES) fail(`${target.pathname} exceeds 2 MiB`);
69+ chunks.push(chunk);
70+ }
71+ return { body: Buffer.concat(chunks), type: (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase() };
72+ } finally {
73+ clearTimeout(timeout);
74+ }
75+}
76+
77+function validateImage(body, type, sha256) {
78+ if (type === "image/png") {
79+ if (body.length < 8 || !body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) fail("Invalid PNG signature");
80+ return "png";
81+ }
82+ if (type !== "image/svg+xml") fail(`Unsupported badge media type: ${type}`);
83+ if (!TRUSTED_SVG_SHA256.has(sha256)) {
84+ fail(`Unreviewed SVG badge ${sha256}; review and pin it before publication`);
85+ }
86+ const source = new TextDecoder("utf-8", { fatal: true }).decode(body);
87+ if (!/<svg\b/iu.test(source)
88+ || /<\s*(?:script|foreignObject|iframe|object|embed)\b/iu.test(source)
89+ || /\son[a-z]+\s*=/iu.test(source)
90+ || /(?:href|src)\s*=\s*["']\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)
91+ || /url\(\s*["']?\s*(?:javascript:|data:|https?:|\/\/)/iu.test(source)) {
92+ fail("Unsafe pinned SVG badge");
93+ }
94+ return "svg";
95+}
96+
97+async function records() {
98+ const query = new URL(`/api/explore/v2.1/catalog/datasets/${DATASET}/records`, ORIGIN);
99+ query.searchParams.set("select", "name_line,shortname_line,transportmode,transportsubmode,status,picto");
100+ query.searchParams.set("where", "status = 'active' and (transportmode in ('metro','tram') or (transportmode = 'rail' and transportsubmode in ('local','suburbanRailway')))");
101+ query.searchParams.set("limit", "100");
102+ const { body, type } = await boundedFetch(query, "application/json");
103+ if (type !== "application/json") fail(`Unexpected records media type: ${type}`);
104+ const payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
105+ if (!Number.isSafeInteger(payload.total_count) || !Array.isArray(payload.results) || payload.results.length !== payload.total_count) {
106+ fail("Incomplete line badge result set");
107+ }
108+ return payload.results;
109+}
110+
111+async function main() {
112+ const selected = (await records()).map((record) => {
113+ const mode = modeFor(record);
114+ const label = safeLabel(record.shortname_line || record.name_line);
115+ const picto = record.picto;
116+ if (mode === undefined || picto === null || typeof picto !== "object" || typeof picto.url !== "string") return undefined;
117+ return { key: `${mode}:${label}`, mode, label, url: picto.url };
118+ }).filter(Boolean).sort((a, b) => a.key.localeCompare(b.key, "en"));
119+
120+ const keys = new Set();
121+ for (const item of selected) {
122+ if (keys.has(item.key)) fail(`Duplicate public badge key: ${item.key}`);
123+ keys.add(item.key);
124+ }
125+
126+ await mkdir(ASSET_ROOT, { recursive: true });
127+ const staging = `${OUTPUT}.tmp-${process.pid}`;
128+ const backup = `${OUTPUT}.bak-${process.pid}`;
129+ await rm(staging, { recursive: true, force: true });
130+ await mkdir(staging);
131+ const manifest = [];
132+ const resolverEntries = [];
133+ try {
134+ for (const item of selected) {
135+ const { body, type } = await boundedFetch(item.url, "image/png,image/svg+xml");
136+ const sha256 = createHash("sha256").update(body).digest("hex");
137+ const extension = validateImage(body, type, sha256);
138+ const filename = `${item.mode.toLowerCase()}-${slug(item.label)}.${extension}`;
139+ await writeFile(join(staging, filename), body, { flag: "wx" });
140+ manifest.push({ key: item.key, file: filename, mediaType: type, bytes: body.length, sha256, source: item.url });
141+ resolverEntries.push([item.key, filename]);
142+ }
143+
144+ const retrievedAt = new Date().toISOString();
145+ const attribution = {
146+ publisher: "Île-de-France Mobilités",
147+ dataset: DATASET,
148+ datasetUrl: DATASET_URL,
149+ datasetLicense: "ODbL 1.0",
150+ datasetLicenseUrl: DATASET_LICENSE_URL,
151+ pictogramReuseTermsUrl: ILICO_TERMS_URL,
152+ retrievedAt,
153+ assets: manifest,
154+ };
155+ await writeFile(join(staging, "attribution.json"), `${JSON.stringify(attribution, null, 2)}\n`);
156+ const resolver = `// Generated by scripts/sync-line-badges.mjs. Do not edit.\nconst ASSETS = new Map(${JSON.stringify(resolverEntries, null, 2)});\n\nexport function lineBadgeAssetUrl(lineMode, lineLabel) {\n const file = ASSETS.get(\`${"${lineMode}:${lineLabel}"}\`);\n return file === undefined ? undefined : new URL(\`../assets/line-badges/${"${file}"}\`, import.meta.url).href;\n}\n`;
157+ const resolverTemp = `${RESOLVER}.tmp-${process.pid}`;
158+ await writeFile(resolverTemp, resolver);
159+ await rm(backup, { recursive: true, force: true });
160+ try { await rename(OUTPUT, backup); } catch (error) { if (error?.code !== "ENOENT") throw error; }
161+ await rename(staging, OUTPUT);
162+ await rename(resolverTemp, RESOLVER);
163+ await rm(backup, { recursive: true, force: true });
164+ const bytes = manifest.reduce((sum, asset) => sum + asset.bytes, 0);
165+ process.stdout.write(`${manifest.length} official badges synchronized (${bytes} bytes)\n`);
166+ } catch (error) {
167+ await rm(staging, { recursive: true, force: true });
168+ try { await rename(backup, OUTPUT); } catch (restoreError) { if (restoreError?.code !== "ENOENT") throw restoreError; }
169+ throw error;
170+ }
171+}
172+
173+await main();