diff --git a/README.md b/README.md
index a7caec3..24ebda5 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,37 @@ runtime à installer) :
> Pense aussi à **fermer les anciens onglets de la liste** avant de recharger :
> une page orpheline rend les clics inertes.
+### Configuration (optionnelle)
+
+Le formulaire de la mairie demande un **organisateur** et un **email** : ils sont
+personnels, donc absents du dépôt. Pour qu'ils soient pré-remplis :
+
+```sh
+cp extension/config.local.json.example extension/config.local.json
+```
+
+puis édite le fichier (il est gitignoré, il ne partira jamais sur le dépôt) :
+
+```json
+{
+ "organisateur": "L'Atelier du Huit",
+ "email": "contact@atelier-huit.fr"
+}
+```
+
+Ce fichier est **facultatif**. Sans lui, tout le reste est pré-rempli (titre,
+dates, heures, lieu, thème, tarifs) : seuls l'organisateur et l'email restent
+vides, et le bandeau du formulaire te le dit.
+
+> ⚠️ Ce fichier ne surcharge que `organisateur` et `email`. Les autres valeurs
+> par défaut (thème « Culture », tarifs « Gratuit », adresse **et coordonnées**
+> du café) vont ensemble et vivent dans `extension/config.js` : c'est là qu'on
+> les change. Y mettre un `cafe` partiel produirait des coordonnées invalides.
+
+> **Après avoir créé ou modifié `config.local.json`** : recharge le module dans
+> `about:debugging`. L'extension lit le fichier au réveil de son arrière-plan,
+> pas à chaque ouverture du formulaire.
+
### Vérifier l'extension
```sh
diff --git a/config.test.ts b/config.test.ts
new file mode 100644
index 0000000..e08fd84
--- /dev/null
+++ b/config.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test";
+// config.js n'exporte RIEN (chargeable en script classique ET en module) :
+// on l'importe pour son effet de bord et on lit le namespace global.
+import "./extension/config.js";
+
+const { DEFAUTS, fusionner, CLE_CONFIG_LOCALE } = (globalThis as any).EchoConfig;
+
+describe("DEFAUTS", () => {
+ test("themeDefaut est une valeur exacte du dropdown mairie (RECHERCHE §3)", () => {
+ expect(DEFAUTS.themeDefaut).toBe("Culture");
+ });
+
+ test("organisateur et email sont vides : jamais inventés, signalés à l'utilisateur", () => {
+ expect(DEFAUTS.organisateur).toBe("");
+ expect(DEFAUTS.email).toBe("");
+ });
+
+ test("le café porte l'adresse et les coordonnées de RECHERCHE §2", () => {
+ expect(DEFAUTS.cafe.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux");
+ expect(DEFAUTS.cafe.coords).toBe("43.53753132806989|1.342981890597508");
+ });
+
+ test("tarifs par défaut : Gratuit", () => {
+ expect(DEFAUTS.tarifs).toBe("Gratuit");
+ });
+});
+
+describe("CLE_CONFIG_LOCALE", () => {
+ test("est la clé de storage partagée background → content script", () => {
+ expect(CLE_CONFIG_LOCALE).toBe("config-locale");
+ });
+});
+
+describe("fusionner", () => {
+ // Un clone frais n'a pas de config.local.json : le repli doit être total,
+ // jamais une exception.
+ test.each([
+ ["absente", undefined],
+ ["null", null],
+ ["une chaîne", "n'importe quoi"],
+ ["un tableau vide", []],
+ ])("locale %s → défauts nus", (_libelle, locale) => {
+ expect(fusionner(DEFAUTS, locale)).toEqual(DEFAUTS);
+ });
+
+ test("organisateur et email surchargent, le reste survit", () => {
+ const fusion = fusionner(DEFAUTS, {
+ organisateur: "L'Atelier du Huit",
+ email: "contact@atelier-huit.fr",
+ });
+
+ expect(fusion.organisateur).toBe("L'Atelier du Huit");
+ expect(fusion.email).toBe("contact@atelier-huit.fr");
+ expect(fusion.themeDefaut).toBe("Culture");
+ expect(fusion.cafe).toEqual(DEFAUTS.cafe);
+ });
+
+ test("ne mute pas DEFAUTS (le module est partagé par tout le monde)", () => {
+ fusionner(DEFAUTS, { organisateur: "Autre" });
+
+ expect(DEFAUTS.organisateur).toBe("");
+ });
+});
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index a68cf6e..1d0c67c 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -126,3 +126,58 @@ deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pa
montrer une ligne vide (cas majoritaire), le content script décide s'il pose
**aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload
détruirait le signal « pas de lieu » dont il a besoin.
+
+## 2026-07-17 — [T1] Pré-remplissage : content scripts classiques + `globalThis`
+
+Le formulaire mairie est pré-rempli par deux content scripts (`config.js`,
+`formulaire-valeurs.js`, `formulaire-mairie.js`), **classiques — pas des modules
+ESM** — qui communiquent par des namespaces `globalThis` (`EchoConfig`,
+`EchoFormulaire`).
+
+**Pourquoi pas ESM :** un content script n'est pas un module (`import` y lève une
+`SyntaxError`), et le `import()` dynamique y est cassé côté Firefox (bugs ouverts
+1803950 / 1536094). Restait à convertir la couture `transfert.js`, figée le
+2026-07-14 : non.
+
+⚠️ **`config.js` et `formulaire-valeurs.js` n'ont NI `import` NI `export`, et
+c'est délibéré — ne pas « corriger ».** Un fichier sans les deux est valide **à la
+fois** comme script classique (content script, background) et comme module ESM
+(`import "./config.js"` depuis `presentation.js` et depuis `bun test`). C'est ce
+qui permet à `presentation.js` de partager l'adresse du café **sans build**. Le
+jour où quelqu'un y ajoute un `export`, les content scripts cassent
+silencieusement.
+
+**Corollaire : l'ordre des tableaux `js` du manifest EST la dépendance**
+(`config` → `valeurs` → `mairie`), testé dans `extension.test.ts`.
+
+**`CLE_EVENEMENT_EN_ATTENTE` est dupliquée** dans `formulaire-valeurs.js`
+(l'original vit dans `transfert.js`, en ESM, inatteignable depuis un content
+script classique). Un **test de dérive** garantit l'égalité des deux.
+
+**Config (a) : défauts committés + `config.local.json` optionnel.** Les défauts
+(thème, tarifs, café) sont dans `config.js` ; seuls organisateur et email sont
+personnels et vivent dans `config.local.json` (gitignoré). Le fichier n'est
+**pas requis** : un clone frais doit marcher, il laisserait sinon l'extension
+cassée par défaut. Absent ou invalide → défauts nus, manque **signalé** dans le
+bandeau, jamais d'exception.
+
+**C'est le background qui lit `config.local.json`**, puis le passe par le
+storage. Un content script ne pourrait le `fetch(runtime.getURL(...))` que si le
+fichier était en `web_accessible_resources` — ce qui **exposerait l'email à la
+page de la mairie**. Le fichier étant optionnel, il ne peut pas non plus être
+listé dans un tableau `js` (Firefox refuse d'installer si un fichier déclaré
+manque) : `fetch` + repli est la seule voie.
+
+**On ne remplit que ce qu'on sait ; le reste est dit, jamais inventé.** Pas
+d'heure « 00:00 » pour une journée entière, pas de `lat|lng` deviné pour un lieu
+hors café, pas d'organisateur par défaut. Ce qui manque part dans le bandeau
+(`aCompleter`). En particulier, **`input_6_35` (accessibilité) est laissé
+intact** : `RECHERCHE.md` le dit « fixe », mais la machine ne sait pas si le lieu
+est accessible — cocher au hasard sur un formulaire **modéré** serait exactement
+le « champ rempli faux » qu'on veut éviter.
+
+**Séparation calcul / DOM** : `formulaire-valeurs.js` est pur et testé (les dates
+sont le vrai risque, invisible sous `TZ=Europe/Paris`) ; `formulaire-mairie.js`
+est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
+happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
+tranche.
diff --git a/docs/RECHERCHE.md b/docs/RECHERCHE.md
index b41652d..3b6fd39 100644
--- a/docs/RECHERCHE.md
+++ b/docs/RECHERCHE.md
@@ -45,24 +45,34 @@ Content script qui **remplit le DOM par id**, puis **l'utilisateur soumet à la
### Mapping des champs (ids confirmés présents dans le HTML)
-| id DOM | name | Contenu | Source |
-|---|---|---|---|
-| `#input_6_42` | `input_42` | Organisateur | config (« L'Atelier du Huit ») |
-| `#input_6_41` | `input_41` | Email | config |
-| `#input_6_1` | `input_1` | Titre | SUMMARY |
-| `#input_6_3` | `input_3` | Description (textarea) | DESCRIPTION |
-| `#input_6_20` | `input_20` | Thème (select) | mapping (voir §3) |
-| `#input_6_31` | `input_31` | Date début `jj/mm/aaaa` | DTSTART |
-| `#input_6_32` | `input_32` | Date fin `jj/mm/aaaa` | DTEND |
-| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | Heure début [HH, MM] | DTSTART |
-| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | Heure fin [HH, MM] | DTEND |
-| `#input_6_35` (`_1`) | `input_35.1` | Accessibilité (case) | fixe |
-| `#input_6_36` | `input_36` | Public ciblé | optionnel |
-| `#input_6_40` | `input_40` | Tarifs | défaut « Gratuit » |
-| `#nova_address` | `nova_address` | Adresse texte | défaut café |
-| `#input_6_18` | `input_18` | `lat\|lng\|adresse` (hidden) | défaut café |
-| `#input_6_34` | `input_34` | **Image (file)** | ⚠️ non remplissable par script (sécurité) → manuel |
-| `#input_6_23`, `#input_6_43` | | Honeypot | laisser vide |
+> ⚠️ **La colonne « balise réelle » a été payée par un bug** (2026-07-17). Le
+> corps de la tâche T1 affirmait « Heures = 2 **selects** » : c'est **faux**, ce
+> sont des ``. Le code l'a suivi et les 4 champs d'heures
+> ont échoué (`champ.options` undefined → TypeError). **Ne pas déduire le type
+> d'un champ de son intitulé : le HTML est la seule source de vérité.** Types
+> ci-dessous vérifiés sur la page réelle le 2026-07-17.
+
+| id DOM | name | Balise réelle | Contenu | Source |
+|---|---|---|---|---|
+| `#input_6_42` | `input_42` | `input[type=text]` | Organisateur | config (« L'Atelier du Huit ») |
+| `#input_6_41` | `input_41` | `input[type=email]` | Email | config |
+| `#input_6_1` | `input_1` | `input[type=text]` | Titre | SUMMARY |
+| `#input_6_3` | `input_3` | `textarea` | Description | DESCRIPTION |
+| `#input_6_20` | `input_20` | **`select`** | Thème | mapping (voir §3) |
+| `#input_6_31` | `input_31` | `input[type=text]` (datepicker) | Date début `jj/mm/aaaa` | DTSTART |
+| `#input_6_32` | `input_32` | `input[type=text]` (datepicker) | Date fin `jj/mm/aaaa` | DTEND |
+| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | **`input[type=number]`** (`min/max`, placeholder `HH`/`MM`) | Heure début [HH, MM] | DTSTART |
+| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | **`input[type=number]`** | Heure fin [HH, MM] | DTEND |
+| `#input_6_35` (`_1`) | `input_35.1` | `input[type=checkbox]` | Accessibilité | ⚠️ laissé **intact** : la machine ne sait pas si le lieu est accessible |
+| `#input_6_36` | `input_36` | | Public ciblé | optionnel |
+| `#input_6_40` | `input_40` | **`textarea`** | Tarifs | défaut « Gratuit » |
+| `#nova_address` | `nova_address` | `input[type=text]` | Adresse texte | défaut café |
+| `#input_6_18` | `input_18` | `input[type=hidden]` | `lat\|lng\|adresse` | défaut café |
+| `#input_6_34` | `input_34` | `input[type=file]` | **Image** | ⚠️ non remplissable par script (sécurité) → manuel |
+| `#input_6_23`, `#input_6_43` | | | Honeypot | laisser vide |
+
+Les 4 champs d'heures acceptent le **zéro-padding** (`"09"`, `"00"`) : une suite
+de chiffres est un *valid floating-point number* au sens HTML.
**Défaut café** : `8 Rue du Pré Vicinal 31270 Cugnaux`
`input_18` = `43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux`
diff --git a/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md
new file mode 100644
index 0000000..67a23a4
--- /dev/null
+++ b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md
@@ -0,0 +1,161 @@
+# Solution : [T1] Content script : pré-remplir le formulaire mairie (+ config)
+
+## Problème résolu
+
+Au clic sur « Créer sur le site mairie », l'extension déposait l'événement dans
+`storage.local` puis ouvrait un **formulaire vide** : le bénévole devait recopier
+à la main titre, dates, heures, adresse, organisateur, email — exactement le
+geste que l'extension existe pour supprimer. La boucle n'était pas fermée.
+
+Objectif tenu : le formulaire s'ouvre **déjà rempli** de ce que la machine sait,
+**laisse vide** ce qu'elle ignore (jamais de valeur inventée), et **dit** dans un
+bandeau ce qui reste à compléter. Le bénévole relit, complète (image, thème,
+enrichissement) et envoie lui-même — **pas d'auto-submit**.
+
+Ce ticket a aussi **introduit la config** de l'extension.
+
+## Approche choisie
+
+**Deux content scripts classiques partageant un scope + namespace `globalThis`**
+(approche 3 du brainstorm), **+ config à défauts committés et surcharge locale
+optionnelle** (option (a)).
+
+Le vrai risque du ticket n'était pas « poser des valeurs dans un DOM » : c'était
+le **calcul des dates** (DTEND exclusif, dates flottantes de journée entière,
+franchissement de minuit) — invisible sous `TZ=Europe/Paris`, la famille de bugs
+qui avait déjà coûté deux passes au ticket précédent. Il fallait donc que ce
+calcul reste **sous `bun test`**.
+
+Trois alternatives écartées, et pourquoi :
+- **Content script monolithique** : logique de dates intestable (rien à importer
+ en `bun test`) → on coderait à l'aveugle les deux bugs les plus chers.
+- **Module ESM en import dynamique** (`import(runtime.getURL(...))` +
+ `web_accessible_resources`) : s'appuie sur un **bug ouvert MV3 de Firefox**
+ ([bugzilla 1803950](https://bugzilla.mozilla.org/show_bug.cgi?id=1803950)) —
+ parier le risque sur le point le plus fragile de la plateforme.
+- **Formater en amont, content script bête** : réviserait une **couture figée
+ trois jours plus tôt** et coupleraient la page liste au formulaire mairie.
+
+L'approche retenue garde le calcul testable **sans** parier sur un bug plateforme
+**ni** rouvrir une couture fraîche.
+
+## Décisions clés
+
+- **`config.js` sans `import` ni `export`, communique par `globalThis`.** Un
+ fichier sans `import`/`export` est valide **à la fois** comme script classique
+ (content script, background) **et** comme module ESM (`import "./config.js"`
+ depuis `presentation.js` et `bun test`). C'est ce qui permet d'absorber
+ `LIEU_PAR_DEFAUT` (fin d'une duplication) sans build. ⚠️ Le jour où quelqu'un
+ ajoute un `export`, tous les content scripts cassent → acté dans `DECISIONS.md`,
+ avec un commentaire en tête de fichier.
+
+- **Le background lit `config.local.json`, pas le content script.** Un content
+ script ne peut `fetch(runtime.getURL(...))` que si la ressource est en
+ `web_accessible_resources` — ce qui **exposerait l'email à la page de la
+ mairie**. Le background lit sans WAR et transmet via `storage.local`.
+
+- **Config optionnelle, dégradation propre.** Fichier absent (clone frais), HTTP
+ non-ok ou JSON invalide → `{}` → organisateur/email vides **signalés dans le
+ bandeau**, tout le reste rempli. Un `config.local.json` **requis** (énoncé
+ littéral) aurait cassé tout clone frais : rejeté. Le fichier n'étant pas
+ garanti présent, il **ne peut pas** être listé dans le manifest (Firefox refuse
+ d'installer si un fichier `js` manque) : `fetch` + repli est la seule voie.
+
+- **`CLE_EVENEMENT_EN_ATTENTE` dupliquée + test de dérive.** La constante vit dans
+ `transfert.js` (ESM), inatteignable depuis un content script classique.
+ Dupliquée volontairement, avec un test qui garantit l'égalité avec l'originale
+ (plutôt que rouvrir la couture pour convertir `transfert.js`).
+
+- **Honnêteté machine/humain.** Jamais de « 00:00 » sur une journée entière
+ (heures laissées vides), jamais de `lat|lng` inventé sur un lieu hors café,
+ `input_6_35` (accessibilité) laissé intact, honeypots intacts. Tout manque
+ remonte dans `aCompleter` (bandeau).
+
+- **`config.local.json.example` dans `extension/`, pas à la racine.** Le
+ background résout `runtime.getURL("config.local.json")` **dans `extension/`** :
+ l'exemple doit être à côté de sa cible de copie.
+
+## Patterns à réutiliser
+
+- **Fichier « bilingue » ESM/classique** : ne poser `import`/`export` nulle part,
+ publier l'API sur `globalThis.MonNamespace`. Chargeable partout dans une
+ extension MV3 sans build. Le prix : une convention à documenter pour qu'un futur
+ agent ne la « corrige » pas en `export`.
+
+- **Séparer calcul pur (testé) / effet de bord (fin, non testé).**
+ `calculerValeurs(payload, config)` est pur et renvoie un objet **plat, prêt à
+ poser** (`dateDebut: "18/07/2026"`, `heureDebut: ["19","30"] | null`, …) ;
+ `formulaire-mairie.js` ne fait que lire le storage et écrire le DOM. Le bug des
+ heures (voir Pièges) a **validé** ce découpage : confiné au poseur, diagnostiqué
+ en une pile d'appels, corrigé en quelques lignes, cœur testé jamais touché.
+
+- **Merge tolérant pour config éditée à la main** : `fusionner(defauts, locale)`
+ traite `null` / non-objet / tableau comme « pas de surcharge » et ne mute pas
+ les défauts. Une config optionnelle ne doit **jamais** lever.
+
+- **Dates de calendrier sans fuseau.** Journée entière = date flottante
+ (RFC 5545) → lire **uniquement** les composantes locales
+ (`getFullYear/getMonth/getDate`), jamais l'heure, jamais un fuseau. DTEND est
+ **exclusif** → reculer la date de fin d'un jour (en composantes locales, jamais
+ « −24h »). Événement horodaté → tout à `Europe/Paris` via `Intl.DateTimeFormat`,
+ heures zéro-paddées. **Épingler `TZ=Europe/Paris` dans les tests ET rejouer sous
+ plusieurs fuseaux** (le bug de fuseau est invisible sous le fuseau de dev).
+
+- **Poser une valeur dans un champ piloté par un framework** : après `.value`,
+ dispatcher `new Event("input", {bubbles:true})` **puis**
+ `new Event("change", {bubbles:true})` (Gravity Forms écoute `change`). Le
+ dispatch est gratuit — ne pas supposer que `.value` suffit.
+
+- **Isoler chaque effet de bord dans son propre `try/catch`.** Un `try` global
+ autour du remplissage laissait la première exception emporter **tous** les
+ champs suivants. Un `try` **par champ** → le champ fautif part dans `manquants`,
+ les autres sont posés. Ferme la classe entière de panne, pas le cas vu.
+
+- **Rendre tout geste automatique visible.** Un bandeau injecté dit ce qui a été
+ fait et ce qui reste ; storage vide (visite directe) → **rien**. Un
+ préremplissage silencieusement partiel soumis à une mairie qui modère est le
+ pire échec possible.
+
+## Pièges à éviter
+
+- 🔴 **Ne jamais déduire le type d'un champ de son intitulé — le HTML est la seule
+ source de vérité.** L'énoncé du ticket affirmait « Heures = 2 **selects**
+ input_6_38_1/_2 ». Le HTML réel dit ``. `poserSelect`
+ faisait `[...champ.options]` sur un `` → `champ.options` undefined →
+ `TypeError`, qui (avant l'isolation par champ) vidait aussi Tarifs, adresse et
+ coordonnées. Corrigé : heures via `poserTexte` (le zéro-padding `"09"`/`"00"`
+ reste un *valid floating-point number* au sens HTML). `RECHERCHE.md` §2 a été
+ corrigée à la racine (colonne « balise réelle » + avertissement) pour qu'un
+ futur agent ne repaie pas ce bug. C'est la cause racine du seul bug du ticket,
+ et il venait d'un **doc/énoncé faux cru sur parole**.
+
+- **Toucher au `manifest.json` impose Décharger + Charger**, pas « Recharger » ni
+ F5. Piège déjà payé plusieurs fois. Fermer aussi les anciens onglets de la liste
+ (page orpheline = clics inertes).
+
+- **DTEND exclusif** : sans recul d'un jour, la date de fin d'un événement
+ multi-jours est **fausse** — et soumise à une mairie qui modère (personne ne
+ relit une date qui *a l'air* juste). Le bug le plus cher du ticket.
+
+- **Course background ↔ content script** (P3 ouvert, non corrigé) :
+ `chargerConfigLocale()` est async ; au tout premier chargement post-install, si
+ le formulaire s'ouvrait avant la fin du `fetch`, organisateur/email seraient
+ vides à tort. Improbable (la valeur persiste ensuite), noté pour mémoire.
+
+- **`chargerConfigLocale()` s'exécute à chaque réveil de l'event page** et écrit
+ `{}` si le fichier est absent. Toute future page d'options qui écrirait dans
+ `storage.local` verrait ses valeurs **écrasées par `{}`** au prochain réveil.
+ Trancher « qui fait autorité, fichier ou UI » fait partie du futur ticket — ce
+ n'est pas « juste ajouter un écran ».
+
+- **Le poseur DOM n'est pas testé unitairement** (aucun harnais DOM dans le repo ;
+ happy-dom n'émule pas le JS de Gravity Forms / Leaflet). C'est le **test humain**
+ qui tranche le rendu réel (datepicker, marqueur carte, bandeau non écrasé par le
+ CSS de la mairie). Le bug des heures l'a prouvé : seul le DOM réel tranche.
+
+- **Config `cafe` partielle interdite** : `fusionner` est shallow → une locale
+ avec un `cafe` incomplet produirait `"undefined|adresse"`. Le README précise que
+ seuls `organisateur` et `email` sont surchargeables.
+
+## Tags
+tags: [firefox-extension, manifest-v3, content-script, globalthis, esm, gravity-forms, dates, timezone, rfc5545, config, bun-test, tdd, vanilla-js]
diff --git a/extension.test.ts b/extension.test.ts
index 3b38d74..a9174f0 100644
--- a/extension.test.ts
+++ b/extension.test.ts
@@ -14,6 +14,12 @@ describe("manifest.json", () => {
expect(manifest.background.scripts).toContain("background.js");
});
+ // Scripts classiques : pas d'import, l'ordre du tableau EST la dépendance.
+ // config.js pose globalThis.EchoConfig, que background.js lit.
+ test("charge config.js avant background.js", () => {
+ expect(manifest.background.scripts).toEqual(["config.js", "background.js"]);
+ });
+
test("demande les host_permissions des deux sources", () => {
expect(manifest.host_permissions).toEqual([
"https://atelier-huit.frama.space/*",
@@ -34,11 +40,47 @@ describe("manifest.json", () => {
});
});
+describe("content_scripts (pré-remplissage du formulaire mairie)", () => {
+ const contentScript = manifest.content_scripts?.[0];
+
+ // URL exacte du formulaire, pas tout ville-cugnaux.fr : on n'injecte pas de
+ // code sur les pages d'un tiers sans raison.
+ test("ne s'injecte que sur le formulaire de la mairie", () => {
+ expect(contentScript.matches).toEqual([
+ "https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/*",
+ ]);
+ });
+
+ test("charge config, valeurs puis poseur (l'ordre EST la dépendance)", () => {
+ expect(contentScript.js).toEqual([
+ "config.js",
+ "formulaire-valeurs.js",
+ "formulaire-mairie.js",
+ ]);
+ });
+
+ test("porte le style du bandeau", () => {
+ expect(contentScript.css).toEqual(["formulaire-mairie.css"]);
+ });
+
+ test("chaque fichier déclaré existe sur le disque", () => {
+ for (const fichier of [...contentScript.js, ...contentScript.css]) {
+ expect(existsSync(join(extensionDir, fichier))).toBe(true);
+ }
+ });
+});
+
describe("ressources référencées", () => {
test("background.js existe", () => {
expect(existsSync(join(extensionDir, "background.js"))).toBe(true);
});
+ test("chaque script d'arrière-plan existe", () => {
+ for (const fichier of manifest.background.scripts) {
+ expect(existsSync(join(extensionDir, fichier))).toBe(true);
+ }
+ });
+
test("liste.html existe", () => {
expect(existsSync(join(extensionDir, "liste.html"))).toBe(true);
});
diff --git a/extension/background.js b/extension/background.js
index 2431475..0bc415c 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -3,3 +3,27 @@
browser.action.onClicked.addListener(() => {
browser.tabs.create({ url: browser.runtime.getURL("liste.html") });
});
+
+// C'est le background qui lit config.local.json, pas le content script : ce
+// dernier ne pourrait le faire que via web_accessible_resources, ce qui
+// exposerait l'email à la page de la mairie. Il la reçoit par le storage.
+//
+// Le fichier est OPTIONNEL (absent d'un clone frais), donc impossible à
+// déclarer dans un tableau `js` du manifest — Firefox refuserait d'installer
+// l'extension. D'où fetch + repli : absent ou JSON invalide → {} → défauts nus,
+// jamais d'exception.
+async function chargerConfigLocale() {
+ let locale = {};
+ try {
+ const reponse = await fetch(browser.runtime.getURL("config.local.json"));
+ if (reponse.ok) locale = await reponse.json();
+ } catch {
+ console.warn("Écho du Huit : config.local.json illisible, défauts utilisés.");
+ }
+ await browser.storage.local.set({ [globalThis.EchoConfig.CLE_CONFIG_LOCALE]: locale });
+}
+
+// À chaque réveil de l'event page, et pas seulement à l'installation : le
+// fichier peut avoir changé depuis, le storage doit refléter le disque.
+chargerConfigLocale();
+browser.runtime.onInstalled.addListener(chargerConfigLocale);
diff --git a/extension/config.js b/extension/config.js
new file mode 100644
index 0000000..bb03a75
--- /dev/null
+++ b/extension/config.js
@@ -0,0 +1,39 @@
+// Défauts committés de l'extension + fusion avec la surcharge locale.
+//
+// ⚠️ Ce fichier n'a NI `import` NI `export`, DÉLIBÉRÉMENT (ne pas « corriger ») :
+// c'est ce qui le rend chargeable à la fois comme content script / script de
+// background (scripts classiques : un `export` y lève une SyntaxError) et comme
+// module ESM (`import "./config.js"` depuis presentation.js et bun test).
+// Il communique donc par `globalThis`. Cf. DECISIONS.md 2026-07-17.
+
+globalThis.EchoConfig = {
+ // Clé de storage : le background écrit, le content script lit.
+ CLE_CONFIG_LOCALE: "config-locale",
+
+ DEFAUTS: {
+ // Vides par défaut : sans config.local.json, on préfère un champ vide
+ // signalé dans le bandeau à un organisateur inventé.
+ organisateur: "",
+ email: "",
+
+ // RECHERCHE.md §3 : valeur exacte du dropdown #input_6_20.
+ themeDefaut: "Culture",
+ tarifs: "Gratuit",
+
+ // RECHERCHE.md §2. `input_18` se compose `${coords}|${adresse}`.
+ cafe: {
+ adresse: "8 Rue du Pré Vicinal 31270 Cugnaux",
+ coords: "43.53753132806989|1.342981890597508",
+ },
+ },
+
+ // Fusion de surface : la locale ne porte que des champs plats
+ // (organisateur, email…). Tolérante par nécessité — `config.local.json` est
+ // optionnel et éditable à la main : absent, `null` ou invalide → défauts nus,
+ // jamais d'exception.
+ fusionner(defauts, locale) {
+ const surcharge =
+ locale && typeof locale === "object" && !Array.isArray(locale) ? locale : {};
+ return { ...defauts, ...surcharge };
+ },
+};
diff --git a/extension/config.local.json.example b/extension/config.local.json.example
new file mode 100644
index 0000000..dc767b5
--- /dev/null
+++ b/extension/config.local.json.example
@@ -0,0 +1,4 @@
+{
+ "organisateur": "L'Atelier du Huit",
+ "email": "contact@atelier-huit.fr"
+}
diff --git a/extension/formulaire-mairie.css b/extension/formulaire-mairie.css
new file mode 100644
index 0000000..f832969
--- /dev/null
+++ b/extension/formulaire-mairie.css
@@ -0,0 +1,49 @@
+/* Bandeau injecté dans la page de la mairie. Propriétés posées explicitement :
+ le CSS du site est hors de notre contrôle et pourrait tout hériter. */
+
+.echo-bandeau {
+ display: block;
+ box-sizing: border-box;
+ margin: 1.5rem 0;
+ padding: 1rem 1.25rem;
+ border: 1px solid #1a73e8;
+ border-left-width: 4px;
+ border-radius: 4px;
+ background: #f2f7fe;
+ color: #1a1a1a;
+ font-family: system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.5;
+ text-align: left;
+}
+
+.echo-bandeau p {
+ margin: 0 0 0.5rem;
+ color: inherit;
+}
+
+.echo-bandeau-titre {
+ font-weight: 700;
+}
+
+.echo-bandeau-liste {
+ margin: 0 0 0.5rem;
+ padding-left: 1.5rem;
+ list-style: disc;
+}
+
+.echo-bandeau-liste li {
+ margin: 0;
+ color: inherit;
+ list-style: disc;
+}
+
+.echo-bandeau-consigne {
+ margin-bottom: 0;
+ font-weight: 700;
+}
+
+.echo-bandeau-alerte {
+ margin: 0.75rem 0 0;
+ color: #b3261e;
+}
diff --git a/extension/formulaire-mairie.js b/extension/formulaire-mairie.js
new file mode 100644
index 0000000..056360e
--- /dev/null
+++ b/extension/formulaire-mairie.js
@@ -0,0 +1,237 @@
+// Poseur : lit le storage, écrit le DOM du formulaire mairie, injecte le
+// bandeau. Volontairement FIN et sans logique métier — tout le calcul est dans
+// formulaire-valeurs.js, sous tests. Ici, c'est le test humain qui tranche.
+//
+// Content script classique : pas d'import, les dépendances arrivent par
+// globalThis (ordre du tableau `js` du manifest — cf. DECISIONS.md 2026-07-17).
+//
+// PAS d'auto-submit : on pré-remplit, l'humain relit et envoie.
+
+// RECHERCHE.md §2 (form id = 6). Non touchés délibérément : input_6_34 (image,
+// impossible par script), input_6_23 / input_6_43 (honeypot), les tokens CSRF,
+// et input_6_35 (accessibilité : la machine ne sait pas si le lieu est
+// accessible — cocher au hasard serait un champ rempli faux).
+const CHAMP_ORGANISATEUR = "input_6_42";
+const CHAMP_EMAIL = "input_6_41";
+const CHAMP_TITRE = "input_6_1";
+const CHAMP_DESCRIPTION = "input_6_3";
+const CHAMP_THEME = "input_6_20";
+const CHAMP_DATE_DEBUT = "input_6_31";
+const CHAMP_DATE_FIN = "input_6_32";
+const CHAMP_HEURE_DEBUT = "input_6_38";
+const CHAMP_HEURE_FIN = "input_6_39";
+const CHAMP_TARIFS = "input_6_40";
+const CHAMP_ADRESSE = "nova_address";
+const CHAMP_COORDONNEES = "input_6_18";
+
+(async () => {
+ const { EchoConfig, EchoFormulaire } = globalThis;
+
+ // Storage illisible ou visite directe du formulaire (sans passer par la
+ // liste) : on ne sait pas s'il y a un travail à faire, donc on ne touche à
+ // rien et on n'affiche aucun bandeau.
+ const stock = await lireStock();
+ if (!stock) return;
+
+ const payload = stock[EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE];
+ if (!payload) return;
+
+ try {
+ // On n'efface PAS le storage à la lecture : le pré-remplissage survit à un
+ // F5 (couture actée — DECISIONS.md 2026-07-14).
+ const config = EchoConfig.fusionner(
+ EchoConfig.DEFAUTS,
+ stock[EchoConfig.CLE_CONFIG_LOCALE],
+ );
+ const valeurs = EchoFormulaire.calculerValeurs(payload, config);
+
+ injecterBandeau(valeurs.aCompleter, remplir(valeurs));
+ } catch (erreur) {
+ // Filet : cette fonction est `async`, une exception y deviendrait une
+ // rejection non gérée — ni remplissage, ni bandeau, ni message. Le bénévole
+ // ne verrait rien du tout. Ce poseur n'étant pas testé unitairement (choix
+ // acté), le filet est sa seule ligne de défense.
+ // Cas réel : la mairie remplace un