# 001 — HistoMétéo MVP — Spécification Technique v15

> **Itération** : v15 — intègre le feedback Reviewer v14 (R44–R45) + nouvelles demandes : **refonte image OG** (couleurs alignées sur le logo, dates en français, pictogramme météo visible) et **bouton de partage Bluesky**.
>
> **Base** : cette spec est un delta de `001-histometeo-mvp.tech.v14.md`. Toutes les sections non modifiées explicitement ci-dessous restent en vigueur telles que définies en v14 (bloc de partage social §3.1–3.4, balises OG/Twitter Card §3.5, endpoint OG §3.7, intégrations R41–R43 §3.8–3.10, masquage §3.11, CSS partage §étape 6).

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v15.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié. AC1–AC11 restent la référence.
- **Scope** : fichiers autorisés à modifier dans cette itération :
  - `src/og_service.py` — refonte majeure de `generate()` (couleurs, layout, icône météo, dates françaises), extension de `aggregate_for_og()` (nouveau champ `dominant_icon`), ajout des helpers `format_date_fr()` et `format_og_date_range()`
  - `src/main.py` — import de `format_og_date_range`, mise à jour de `seo_meteo_page()` (titre OG en dates françaises), mise à jour de `get_og_image()` (passage de `dominant_icon` à `generate()`)
  - `public/app.js` — ajout du lien Bluesky dans `renderShareBlock()`
  - `tests/test_og_service.py` — mise à jour des tests T-01, T-04, T-05, T-06, T-07 (changement de signature/retour) + ajout de nouveaux tests
  - `tests/test_api.py` — mise à jour du test T-08 (format date français dans `og:title`)
  - `src/assets/weather-icons/` — **NOUVEAU** : 8 fichiers PNG d'icônes météo (256×256, fond transparent)
- **Fichiers v14 non modifiés dans cette itération** (restent tels quels) :
  - `public/index.html` — aucun changement
  - `public/style.css` — aucun changement (le bouton Bluesky utilise le style générique `.share-btn`)
  - `src/config.py` — aucun changement
  - `requirements.txt` — aucun changement
  - `Dockerfile` — aucun changement (le `COPY src/assets /app/src/assets` existant inclut automatiquement le nouveau sous-dossier `weather-icons/`)
  - `README.md` — aucun changement
- **Forbidden changes** (hérités de v14 et maintenus) :
  - `src/weather_service.py`, `src/cache.py`, `src/commune_service.py`, `src/normals_service.py` — aucune modification
  - `tests/` — aucune modification des tests existants hérités de ≤ v13 (61 tests). Les tests ajoutés en v14 (T-01 à T-14) **peuvent** être modifiés dans cette itération pour s'adapter aux changements de signature/comportement (justification : changement de l'interface publique de `aggregate_for_og` et de `generate()`, changement du format de `og:title`).
  - `docs/` — aucune modification des specs existantes (hors ce fichier)
  - `.github/`, `pyproject.toml`, `public/assets/` — aucune modification
- **Invariants** (tous ceux de v14, INV-1 à INV-17, restent en vigueur) + :
  - **INV-18** (nouveau) : Les icônes météo dans `src/assets/weather-icons/` sont des fichiers PNG statiques sous licence open-source compatible (MIT, Apache 2.0 ou SIL OFL). Elles ne sont pas générées dynamiquement.
  - **INV-19** (nouveau) : Le formatage des dates en français utilise un dictionnaire Python statique de noms de mois. Aucune dépendance sur `locale`, `babel` ou tout autre module de localisation.
- **Done when** :
  - Tous les critères « Done when » de v14 restent valides, **sauf** les points suivants qui sont remplacés :
    - ~~5 boutons~~ → **6 boutons** : « Copier le lien », « Facebook », « X », « WhatsApp », « LinkedIn », **« Bluesky »**
    - ~~Image OG avec fond `#1a5276` et texte blanc~~ → **Image OG avec fond `#fefefe`** (identique au fond de l'image du logo, pour superposition invisible), **texte en couleurs du logo** (`#429ab9` bleu, `#85c65a` vert), **pictogramme météo visible** (icône PNG 160×160), **dates en français**, **dimensions 1200×630** (ratio 1.91:1, format optimal Open Graph pour partage smartphone et desktop)
    - ~~`og:title` avec dates ISO~~ → **`og:title` avec dates en français** : « Quel temps faisait-il à Huttenheim le 12 mars 2026 ? » (jour unique) ou « Quel temps faisait-il à Huttenheim du 9 au 11 mars 2026 ? » (période)
  - R44 intégré : flèche `→` au lieu de `->` dans l'image OG et le texte de température
  - Les 61 tests hérités de ≤ v13 passent sans modification
  - Les tests v14 modifiés (T-01, T-04–T-07, T-08) passent avec les nouvelles assertions
  - Les nouveaux tests v15 (T-15 à T-21) passent
  - Total attendu : ≥ 82 tests PASSED

---

## 1) Objectif technique

Aligner l'image OG dynamique sur l'identité visuelle du site (couleurs du logo, typographie, pictogramme météo) et améliorer la lisibilité des previews sur les réseaux sociaux (dates en français, format « jour unique » vs « période »). Ajouter Bluesky comme sixième canal de partage.

---

## 2) Analyse du brief

### Nouvelles demandes

| #   | Besoin                                                          | Source          | Complexité | Impact  |
| --- | --------------------------------------------------------------- | --------------- | ---------- | ------- |
| D31 | Refonte image OG : couleurs logo, dates FR, pictogramme météo   | Demande produit | Moyen      | Visuel  |
| D32 | Bouton de partage Bluesky                                       | Demande produit | Trivial    | Partage |
| R44 | Flèche Unicode `→` au lieu de `->` dans l'image OG              | Feedback v14    | Trivial    | Visuel  |
| R45 | Surveillance test T-13 si homepage change de méthode de serving | Feedback v14    | Aucun      | Note    |

### Contraintes supplémentaires

- **Pas de nouvelle dépendance** : ni Python, ni frontend. Pillow (déjà installé) gère le collage des icônes PNG.
- **Les icônes météo sont des fichiers PNG statiques** (INV-18), pas des emojis (Pillow ne les rend pas correctement).
- **Le formatage des dates en français est un helper Python pur** (INV-19) — un simple dictionnaire de mois, sans `locale` ni `babel`.
- **Le bouton Bluesky** utilise l'URL publique d'intent Bluesky (`https://bsky.app/intent/compose?text=...`), aucune clé API requise (INV-4 préservé).

### Risques additionnels

| #   | Risque                                                            | Probabilité | Impact | Mitigation                                                                                                            |
| --- | ----------------------------------------------------------------- | ----------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| 4   | Les icônes PNG météo ne sont pas incluses lors du déploiement     | Faible      | Moyen  | Le `COPY src/assets /app/src/assets` du Dockerfile inclut récursivement `weather-icons/`. Test T-20 vérifie le rendu. |
| 5   | Le format de date français est incorrect pour un cas limite (1er) | Faible      | Faible | Test T-16 vérifie explicitement le cas « 1er ». Le helper est pur et sans état, facilement testable.                  |

---

## 3) Design minimal proposé

> Les sections §3.1 à §3.5, §3.7 à §3.11 de la v14 restent inchangées, sauf les modifications explicites ci-dessous.

### 3.6) Image OG dynamique — REFONTE (remplace intégralement §3.6 de v14)

#### 3.6.1) Palette de couleurs

L'image OG utilise désormais les couleurs extraites directement de l'image du logo (`public/assets/logo-histometeo.png`) :

```python
BG_COLOR = "#fefefe"           # Fond — identique au fond de l'image PNG du logo (superposition invisible)
BORDER_COLOR = "#429ab9"       # Barre d'accentuation haute — bleu logo
HEADING_COLOR = "#429ab9"      # Nom de commune — bleu logo (couleur dominante)
DATE_COLOR = "#6b7280"         # Dates — gris neutre (ni présent dans le logo, rôle de lisibilité secondaire)
DATA_COLOR = "#429ab9"         # Températures — bleu logo (donnée clé, même couleur que le heading)
ACCENT_COLOR = "#85c65a"       # Condition dominante — vert logo (couleur secondaire)
FOOTER_COLOR = "#6b7280"       # URL histometeo.com — gris neutre
```

**Justification** : le fond du fichier PNG `logo-histometeo.png` est `#fefefe` (blanc quasi-pur, vérifié par analyse pixel : 72% des pixels). En utilisant exactement `#fefefe` comme fond de l'image OG, le logo se fond de manière invisible, sans aucun artéfact de bordure. Les couleurs de texte sont les deux couleurs dominantes extraites du logo par analyse HSV — bleu `#429ab9` (23 000 pixels, teinte 180-210°) et vert `#85c65a` (11 600 pixels, teinte 90-120°). Aucune couleur du fond (`#fefefe`) n'est utilisée pour le texte. Le contraste sur fond quasi-blanc est excellent (ratio ≥ 4.5:1 pour les deux couleurs).

**Dimensions** : 1200×630 px (ratio 1.91:1). C'est le format recommandé par Facebook, Twitter/X, LinkedIn, WhatsApp et Bluesky pour les previews `summary_large_image`. Ce format garantit un affichage optimal tant sur smartphone que sur desktop — aucune plateforme majeure ne recadre l'image à ce ratio.

#### 3.6.2) Layout de l'image

```
┌──────────────────────────────────────────────────────────┐
│ ══════════════ barre #2a7daf (6px) ═══════════════════   │
│                                                          │
│  [LOGO 60px]                                             │
│                                                          │
│  Huttenheim                    ┌──────────────┐          │
│  le 12 mars 2026               │  [ICÔNE      │          │
│                                │   MÉTÉO      │          │
│  3,9 °C → 13,7 °C             │   160×160]   │          │
│  Ciel dégagé                   └──────────────┘          │
│                                                          │
│  histometeo.com                                          │
└──────────────────────────────────────────────────────────┘
```

**Positionnement** :

| Élément        | Position (x, y)           | Taille police | Couleur                     |
| -------------- | ------------------------- | ------------- | --------------------------- |
| Barre haute    | (0, 0) → (1200, 6)        | —             | `BORDER_COLOR` (`#429ab9`)  |
| Logo           | (60, 20)                  | hauteur 60px  | —                           |
| Nom de commune | (60, y_after_logo)        | 52pt          | `HEADING_COLOR` (`#429ab9`) |
| Date(s)        | (60, y + 70)              | 36pt          | `DATE_COLOR` (`#6b7280`)    |
| Températures   | (60, y + 60)              | 52pt          | `DATA_COLOR` (`#429ab9`)    |
| Condition      | (60, y + 70)              | 28pt          | `ACCENT_COLOR` (`#85c65a`)  |
| Icône météo    | (960, 180) — centre-droit | 160×160 px    | —                           |
| Footer         | (60, HEIGHT - 55)         | 28pt          | `FOOTER_COLOR` (`#6b7280`)  |

**L'icône météo** est positionnée à droite, centrée verticalement dans la zone de contenu. Si aucune icône n'est disponible (condition absente ou fichier PNG manquant), la zone droite est simplement vide — pas de placeholder.

#### 3.6.3) Icônes météo PNG

**Répertoire** : `src/assets/weather-icons/`

**Source recommandée** : [Meteocons par Bas Milius](https://github.com/basmilius/weather-icons) (licence MIT) — exporter les variantes « fill » en PNG 256×256. Toute autre source open-source (MIT, Apache 2.0, SIL OFL) est acceptable.

**Contrainte visuelle** : les icônes doivent avoir un fond transparent et des couleurs suffisamment contrastées sur le fond `#fefefe`. Les variantes « fill » (colorées) sont préférées aux variantes « outline ».

**8 fichiers requis** :

| Fichier            | Emojis mappés | Conditions WMO                                  |
| ------------------ | ------------- | ----------------------------------------------- |
| `sun.png`          | ☀️, 🌤️        | Ciel dégagé, Principalement dégagé              |
| `cloud-sun.png`    | ⛅            | Partiellement nuageux                           |
| `cloud.png`        | ☁️            | Couvert                                         |
| `fog.png`          | 🌫️            | Brouillard, Brouillard givrant                  |
| `drizzle.png`      | 🌦️            | Bruine légère/modérée, Averses légères          |
| `rain.png`         | 🌧️            | Pluie, Bruine forte, Averses modérées/violentes |
| `snow.png`         | 🌨️            | Neige, Grains de neige, Averses de neige        |
| `thunderstorm.png` | ⛈️            | Orage, Orage avec grêle                         |

**Mapping dans `og_service.py`** :

```python
_WEATHER_ICON_MAP: dict[str, str] = {
    "☀️": "sun.png",
    "🌤️": "sun.png",
    "⛅": "cloud-sun.png",
    "☁️": "cloud.png",
    "🌫️": "fog.png",
    "🌦️": "drizzle.png",
    "🌧️": "rain.png",
    "🌨️": "snow.png",
    "⛈️": "thunderstorm.png",
}
```

#### 3.6.4) Formatage des dates en français

**Fonctions publiques** dans `og_service.py` (utilisées aussi par `main.py`) :

```python
_MOIS_FR: dict[int, str] = {
    1: "janvier", 2: "février", 3: "mars", 4: "avril",
    5: "mai", 6: "juin", 7: "juillet", 8: "août",
    9: "septembre", 10: "octobre", 11: "novembre", 12: "décembre",
}


def format_date_fr(iso_date: str) -> str:
    """'2026-03-12' → '12 mars 2026'. Le 1er du mois → '1er mars 2026'."""
    y, m, d = iso_date.split("-")
    day = int(d)
    day_str = "1er" if day == 1 else str(day)
    return f"{day_str} {_MOIS_FR[int(m)]} {y}"


def format_og_date_range(start: str, end: str) -> str:
    """
    Retourne une expression de date en français pour le titre OG et l'image.

    - Jour unique : 'le 12 mars 2026'
    - Même mois   : 'du 9 au 11 mars 2026'
    - Mois croisés: 'du 28 février au 3 mars 2026'
    """
    if start == end:
        return f"le {format_date_fr(start)}"
    sy, sm, sd = start.split("-")
    ey, em, _ = end.split("-")
    if sm == em and sy == ey:
        start_day = int(sd)
        start_day_str = "1er" if start_day == 1 else str(start_day)
        return f"du {start_day_str} au {format_date_fr(end)}"
    return f"du {format_date_fr(start)} au {format_date_fr(end)}"
```

**Exemples** :

| start        | end          | Résultat                       |
| ------------ | ------------ | ------------------------------ |
| `2026-03-12` | `2026-03-12` | `le 12 mars 2026`              |
| `2026-03-09` | `2026-03-11` | `du 9 au 11 mars 2026`         |
| `2026-02-28` | `2026-03-03` | `du 28 février au 3 mars 2026` |
| `2026-01-01` | `2026-01-15` | `du 1er au 15 janvier 2026`    |
| `2026-05-01` | `2026-05-01` | `le 1er mai 2026`              |

#### 3.6.5) Extension de `aggregate_for_og()`

Ajout du champ `dominant_icon` dans le retour :

```python
def aggregate_for_og(daily_summary: list[dict]) -> dict[str, float | str | None]:
    if not daily_summary:
        return {"temp_min": None, "temp_max": None, "dominant_desc": None, "dominant_icon": None}

    temp_mins = [day.get("temp_min") for day in daily_summary if day.get("temp_min") is not None]
    temp_maxs = [day.get("temp_max") for day in daily_summary if day.get("temp_max") is not None]

    desc_counts: Counter[str] = Counter()
    for day in daily_summary:
        description = day.get("description")
        if description:
            desc_counts[description] += 1

    dominant_desc = desc_counts.most_common(1)[0][0] if desc_counts else None

    # Récupérer l'icône (emoji) du jour dont la description correspond
    dominant_icon = None
    if dominant_desc:
        for day in daily_summary:
            if day.get("description") == dominant_desc:
                dominant_icon = day.get("icon")
                break

    return {
        "temp_min": min(temp_mins) if temp_mins else None,
        "temp_max": max(temp_maxs) if temp_maxs else None,
        "dominant_desc": dominant_desc,
        "dominant_icon": dominant_icon,
    }
```

**Pas de modification de `weather_service.py`** — le champ `icon` existe déjà dans chaque entrée de `daily_summary`.

#### 3.6.6) Méthode `generate()` mise à jour

```python
class OGImageService:
    WIDTH = 1200
    HEIGHT = 630
    BG_COLOR = "#fefefe"           # Fond identique au fond de l'image du logo
    BORDER_COLOR = "#429ab9"       # Bleu logo
    HEADING_COLOR = "#429ab9"      # Bleu logo
    DATE_COLOR = "#6b7280"         # Gris neutre
    DATA_COLOR = "#429ab9"         # Bleu logo
    ACCENT_COLOR = "#85c65a"       # Vert logo
    FOOTER_COLOR = "#6b7280"       # Gris neutre
    ICON_SIZE = 160

    def __init__(self) -> None:
        src_dir = Path(__file__).resolve().parent
        root_dir = src_dir.parent
        self._font_path = src_dir / "assets" / "fonts" / "Inter-SemiBold.ttf"
        self._logo_path = root_dir / "public" / "assets" / "logo-histometeo.png"
        self._icons_dir = src_dir / "assets" / "weather-icons"
        self._cache: TTLCache[bytes] = TTLCache(
            ttl_seconds=OG_IMAGE_CACHE_TTL_SECONDS,
            max_entries=OG_IMAGE_CACHE_MAX_ENTRIES,
        )

    def _load_font(self, size: int):
        try:
            return ImageFont.truetype(str(self._font_path), size=size)
        except OSError:
            return ImageFont.load_default()

    def _load_weather_icon(self, dominant_icon: str | None) -> Image.Image | None:
        """Charge l'icône météo PNG correspondant à l'emoji dominant."""
        if not dominant_icon:
            return None
        filename = _WEATHER_ICON_MAP.get(dominant_icon)
        if not filename:
            return None
        icon_path = self._icons_dir / filename
        try:
            icon = Image.open(icon_path).convert("RGBA")
            icon = icon.resize((self.ICON_SIZE, self.ICON_SIZE), Image.LANCZOS)
            return icon
        except (OSError, ValueError):
            return None

    def generate(
        self,
        commune_name: str,
        start: str,
        end: str,
        temp_min: float | None,
        temp_max: float | None,
        dominant_desc: str | None,
        dominant_icon: str | None,       # ← NOUVEAU paramètre
    ) -> bytes:
        cache_key = f"{commune_name}|{start}|{end}"
        cached = self._cache.get(cache_key)
        if cached is not None:
            return cached

        image = Image.new("RGB", (self.WIDTH, self.HEIGHT), color=self.BG_COLOR)
        draw = ImageDraw.Draw(image)

        font_lg = self._load_font(52)
        font_md = self._load_font(36)
        font_sm = self._load_font(28)

        # Barre d'accentuation haute
        draw.rectangle([(0, 0), (self.WIDTH, 6)], fill=self.BORDER_COLOR)

        y_cursor = 20

        # Logo
        try:
            logo = Image.open(self._logo_path).convert("RGBA")
            logo_height = 60
            logo_width = int(logo.width * logo_height / logo.height)
            logo = logo.resize((logo_width, logo_height), Image.LANCZOS)
            image.paste(logo, (60, y_cursor), logo)
            y_cursor += logo_height + 30
        except (OSError, ValueError):
            y_cursor += 20

        # Nom de commune
        draw.text((60, y_cursor), commune_name, font=font_lg, fill=self.HEADING_COLOR)
        y_cursor += 70

        # Dates en français
        date_text = format_og_date_range(start, end)
        draw.text((60, y_cursor), date_text, font=font_md, fill=self.DATE_COLOR)
        y_cursor += 60

        # Températures avec flèche Unicode →
        if temp_min is not None and temp_max is not None:
            temp_text = f"{temp_min:.1f} °C → {temp_max:.1f} °C"
            draw.text((60, y_cursor), temp_text, font=font_lg, fill=self.DATA_COLOR)
            y_cursor += 70

        # Condition dominante
        if dominant_desc and dominant_desc != "N/A":
            condition_text = f"Conditions : {dominant_desc.lower()}"
            draw.text((60, y_cursor), condition_text, font=font_sm, fill=self.ACCENT_COLOR)

        # Icône météo — grande, positionnée à droite
        weather_icon = self._load_weather_icon(dominant_icon)
        if weather_icon:
            icon_x = self.WIDTH - self.ICON_SIZE - 80
            icon_y = (self.HEIGHT - self.ICON_SIZE) // 2
            image.paste(weather_icon, (icon_x, icon_y), weather_icon)

        # Footer
        draw.text((60, self.HEIGHT - 55), "histometeo.com", font=font_sm, fill=self.FOOTER_COLOR)

        buffer = BytesIO()
        image.save(buffer, format="PNG", optimize=True)
        png_bytes = buffer.getvalue()
        self._cache.set(cache_key, png_bytes)
        return png_bytes
```

**Points clés de la refonte** :

- Le fond `#fefefe` est exactement celui du fichier PNG du logo — le logo se superpose sans aucune bordure visible.
- La barre d'accentuation haute (`6px`, couleur `#429ab9` bleu logo) apporte une touche visuelle de marque.
- Les dates utilisent `format_og_date_range()` — format français lisible, pas ISO.
- La flèche `→` (Unicode) remplace `->` (R44 intégré).
- L'icône météo est un PNG collé via `Image.paste()` avec masque alpha. Si le PNG est introuvable, la zone est simplement vide.
- Les couleurs de texte (`#429ab9` bleu, `#85c65a` vert) sont extraites du logo — pas les variables CSS du site.
- Le footer utilise `#6b7280` (gris neutre) — non présent dans le logo, rôle de lisibilité secondaire.

### 3.12) Mise à jour du titre OG dans `seo_meteo_page()` (backend)

Dans `src/main.py`, importer le nouveau helper et mettre à jour la construction du titre :

```python
from src.og_service import OGImageService, aggregate_for_og, format_og_date_range

# Dans seo_meteo_page() :
date_range = format_og_date_range(start, end)
og_title = f"Quel temps faisait-il à {commune_name} {date_range}\u00a0?"
```

**Exemples de titres OG résultants** :

| Route                                        | Titre OG                                                             |
| -------------------------------------------- | -------------------------------------------------------------------- |
| `/meteo/huttenheim-67/2026-03-12/2026-03-12` | Quel temps faisait-il à Huttenheim le 12 mars 2026 ?                 |
| `/meteo/bordeaux-33/2026-03-09/2026-03-11`   | Quel temps faisait-il à Bordeaux du 9 au 11 mars 2026 ?              |
| `/meteo/paris-75/2026-01-01/2026-01-15`      | Quel temps faisait-il à Paris du 1er au 15 janvier 2026 ?            |
| `/meteo/nice-06/2025-12-28/2026-01-03`       | Quel temps faisait-il à Nice du 28 décembre 2025 au 3 janvier 2026 ? |

### 3.13) Mise à jour de l'endpoint `get_og_image()` (backend)

Dans `src/main.py`, passer le nouveau paramètre `dominant_icon` à `generate()` :

```python
# Dans get_og_image(), après l'agrégation :
dominant_icon = og_agg.get("dominant_icon")

image_bytes = og_service.generate(
    commune_name=commune_name,
    start=start,
    end=end,
    temp_min=temp_min,
    temp_max=temp_max,
    dominant_desc=dominant_desc,
    dominant_icon=dominant_icon,       # ← NOUVEAU
)
```

En cas de fallback (exception), `dominant_icon` reste `None` → pas d'icône sur l'image. Comportement non-dégradant.

### 3.14) Bouton Bluesky (frontend)

Dans `renderShareBlock()` (`public/app.js`), ajouter une entrée Bluesky dans le tableau `shareLinks` :

```js
const shareLinks = [
  {
    className: "share-btn-facebook",
    label: "Facebook",
    href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
  },
  {
    className: "share-btn-x",
    label: "X",
    href: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`,
  },
  {
    className: "share-btn-whatsapp",
    label: "WhatsApp",
    href: `https://api.whatsapp.com/send?text=${encodedText}%0A${encodedUrl}`,
  },
  {
    className: "share-btn-linkedin",
    label: "LinkedIn",
    href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
  },
  // ← NOUVEAU
  {
    className: "share-btn-bluesky",
    label: "Bluesky",
    href: `https://bsky.app/intent/compose?text=${encodedText}%0A${encodedUrl}`,
  },
];
```

**URL de partage Bluesky** : `https://bsky.app/intent/compose?text={text}%0A{url}` — URL publique officielle d'intent Bluesky, sans clé API (INV-4 préservé).

**Position** : dernier dans la liste, après LinkedIn. Le `<a>` est construit avec le même pattern que les autres liens (`target="_blank"`, `rel="noopener"`, `aria-label`).

**Aucun CSS additionnel requis** — le bouton utilise le style générique `.share-btn` existant. La classe `.share-btn-bluesky` sert d'identifiant pour un éventuel styling futur.

---

## 4) Plan d'implémentation

### Étape 1 — Assets : icônes météo

**Fichier** : `src/assets/weather-icons/` (NOUVEAU répertoire)

1. **Créer** le répertoire `src/assets/weather-icons/`.
2. **Télécharger** 8 icônes PNG (256×256, fond transparent) depuis une source open-source (recommandé : [Meteocons](https://github.com/basmilius/weather-icons), licence MIT).
3. **Nommer** les fichiers conformément au tableau §3.6.3 : `sun.png`, `cloud-sun.png`, `cloud.png`, `fog.png`, `drizzle.png`, `rain.png`, `snow.png`, `thunderstorm.png`.

**Vérifiable** : les 8 fichiers existent dans le répertoire. Chaque fichier est un PNG valide avec fond transparent.

### Étape 2 — Backend : refonte `og_service.py`

**Fichier** : `src/og_service.py`

1. **Ajouter** le dictionnaire `_MOIS_FR` et les fonctions `format_date_fr()` et `format_og_date_range()`.
2. **Ajouter** le mapping `_WEATHER_ICON_MAP`.
3. **Modifier** `aggregate_for_og()` : ajouter le champ `dominant_icon` au retour.
4. **Modifier** `OGImageService` :
   - Remplacer les constantes de couleur (`BG_COLOR`, `TEXT_COLOR`, `ACCENT_COLOR` → nouvelle palette §3.6.1).
   - Ajouter `_icons_dir` et `_load_weather_icon()`.
   - Réécrire `generate()` selon §3.6.6 (nouveau layout, couleurs, icône, dates FR, flèche Unicode).

**Vérifiable** : `format_date_fr("2026-03-12")` → `"12 mars 2026"`. `format_og_date_range("2026-03-12", "2026-03-12")` → `"le 12 mars 2026"`. `generate(...)` retourne un PNG valide avec fond `#fefefe`.

### Étape 3 — Backend : mise à jour `main.py`

**Fichier** : `src/main.py`

1. **Ajouter** `format_og_date_range` à l'import depuis `src.og_service`.
2. **Modifier** `seo_meteo_page()` : utiliser `format_og_date_range(start, end)` dans `og_title`.
3. **Modifier** `get_og_image()` : extraire `dominant_icon` de l'agrégation et le passer à `og_service.generate()`.

**Vérifiable** : `GET /meteo/huttenheim-67/2026-03-12/2026-03-12` → `og:title` contient « le 12 mars 2026 ».

### Étape 4 — Frontend : bouton Bluesky

**Fichier** : `public/app.js`

1. **Ajouter** l'entrée Bluesky dans `shareLinks` de `renderShareBlock()`, après LinkedIn.

**Vérifiable** : après une recherche, le bloc de partage affiche 6 boutons dont « Bluesky ». Le lien ouvre `bsky.app/intent/compose?text=...`.

### Étape 5 — Tests + Validation

1. **Modifier** les tests T-01, T-04, T-05, T-06, T-07 dans `test_og_service.py` pour refléter le nouveau champ `dominant_icon` et le paramètre `dominant_icon` de `generate()`.
2. **Modifier** le test T-08 dans `test_api.py` pour vérifier le format de date français dans `og:title`.
3. **Ajouter** les tests T-15 à T-21 (voir §6).
4. **Exécuter** `pytest tests/ -v` — 61 tests hérités + tests v14 modifiés + tests v15 → ≥ 82 PASSED.
5. **Vérifier** les invariants :
   - `grep innerHTML app.js` → 0 occurrence (INV-7)
   - `grep "window.innerWidth\|matchMedia\|userAgent" app.js` → 0 occurrence (INV-12)
6. **Tester manuellement** les scénarios TM-55 à TM-60 (§6).

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Les emojis contiennent des variation selectors** (U+FE0F) — le mapping `_WEATHER_ICON_MAP` doit inclure la forme exacte de l'emoji telle qu'elle est stockée dans `WMO_DESCRIPTIONS`. Exemple : `"☀️"` (avec variation selector) et `"☀"` (sans) sont des clés différentes. Tester avec les valeurs exactes de `WMO_DESCRIPTIONS`.

2. **Pillow en mode RGB ne supporte pas l'alpha dans `fill`** — ne pas utiliser de couleurs hexadécimales avec alpha (`#ffffffaa`). Utiliser des couleurs RGB opaques. Si un effet de transparence est nécessaire, utiliser `Image.alpha_composite()` sur une image RGBA.

3. **L'icône PNG doit être `.convert("RGBA")`** avant `Image.paste()` avec masque — sinon le fond transparent n'est pas respecté et un carré noir apparaît. Le troisième argument de `paste()` est le masque alpha.

4. **Le `1er` français** — ne pas oublier la règle `day == 1 → "1er"`. C'est le seul ordinal en français qui change de forme. Les tests T-16 et T-21 couvrent ce cas.

5. **Le format de la clé de cache n'a pas changé** — `cache_key = f"{commune_name}|{start}|{end}"`. Cela signifie que si l'image est en cache depuis la v14 (ancien design), elle sera servie avec l'ancien visuel jusqu'à expiration du TTL (7 jours). En production, un déploiement avec nouvelle version de code recrée une instance fraîche du service, donc le cache est vide. En développement, relancer le serveur suffit.

### Zones de dérive

- **Ne pas générer les icônes météo dynamiquement** via Pillow draw. Utiliser les PNG statiques (INV-18).
- **Ne pas utiliser `babel`, `locale`, `datetime.strftime` avec locale** pour le formatage de date — le helper `format_date_fr` est un dictionnaire pur (INV-19).
- **Ne pas ajouter de style CSS spécifique au bouton Bluesky** — le style générique `.share-btn` est suffisant. La classe `.share-btn-bluesky` est un marqueur, pas un hook de style.
- **Ne pas modifier `weather_service.py`** pour ajouter des données — `daily_summary` contient déjà `icon` et `description`, c'est suffisant pour l'agrégation.

### Simplifications autorisées

- Si une icône météo PNG est manquante pour un emoji donné, l'image est générée sans icône. Pas d'erreur.
- La position de l'icône météo (x=960, y=centré) peut être ajustée de ±50px si le résultat visuel est meilleur.
- Le logo dans l'image OG peut être omis si le fichier `logo-histometeo.png` n'est pas accessible (existant, inchangé).

### Décisions explicitement interdites

- Toutes les interdictions v14 restent en vigueur (pas d'`innerHTML`, pas de `!important`, pas de `window.open()`, pas de stockage, pas de dépendance frontend, pas d'injection HTML non échappée).
- **Interdit** : utiliser `locale.setlocale()` ou `babel.dates` pour le formatage des dates.
- **Interdit** : inclure des icônes météo dont la licence n'est pas open-source compatible.
- **Interdit** : modifier la signature ou le comportement de `computeAggregates()` côté frontend.

---

## 6) Stratégie de tests

### Tests existants (≤ v13)

Les **61 tests hérités** passent à l'identique. Aucun n'est modifié ni supprimé.

### Tests v14 modifiés (justification : changement d'interface publique)

| Test | Modification                                                                                          |
| ---- | ----------------------------------------------------------------------------------------------------- |
| T-01 | Vérifier que le retour de `aggregate_for_og` contient `dominant_icon` en plus des 3 champs existants. |
| T-04 | Ajouter `dominant_icon="☀️"` au call de `generate()`.                                                 |
| T-05 | Idem T-04 — ajout du paramètre `dominant_icon`.                                                       |
| T-06 | Appeler `generate(..., dominant_icon=None)` — le mode fallback reste un PNG valide.                   |
| T-07 | Ajout du paramètre `dominant_icon` dans les deux appels de cache.                                     |
| T-08 | L'assertion sur `og:title` vérifie désormais une date en français (ex: « mars ») et non ISO.          |

### Nouveaux tests v15

**Fichier** : `tests/test_og_service.py` (ajouts)

| #    | Test                                          | Type     | Assertion                                                                                          |
| ---- | --------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- |
| T-15 | `test_format_date_fr_basic`                   | Unitaire | `format_date_fr("2026-03-12")` → `"12 mars 2026"`.                                                 |
| T-16 | `test_format_date_fr_first_of_month`          | Unitaire | `format_date_fr("2026-01-01")` → `"1er janvier 2026"`.                                             |
| T-17 | `test_format_og_date_range_single_day`        | Unitaire | `format_og_date_range("2026-03-12", "2026-03-12")` → `"le 12 mars 2026"`.                          |
| T-18 | `test_format_og_date_range_same_month`        | Unitaire | `format_og_date_range("2026-03-09", "2026-03-11")` → `"du 9 au 11 mars 2026"`.                     |
| T-19 | `test_format_og_date_range_cross_month`       | Unitaire | `format_og_date_range("2026-02-28", "2026-03-03")` → `"du 28 février au 3 mars 2026"`.             |
| T-20 | `test_aggregate_for_og_returns_dominant_icon` | Unitaire | `aggregate_for_og([{"description": "Ciel dégagé", "icon": "☀️", ...}])["dominant_icon"]` → `"☀️"`. |
| T-21 | `test_og_image_generate_with_weather_icon`    | Unitaire | `generate(..., dominant_icon="☀️")` retourne un PNG valide de 1200×630 px.                         |

### Tests manuels additionnels (v15)

| #     | Scénario                                                      | Résultat attendu                                                                                                                                                                        |
| ----- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TM-55 | Image OG : vérifier les couleurs (fond logo, texte bleu/vert) | L'image à `/api/og-image/huttenheim-67/2026-03-12/2026-03-12` a un fond `#fefefe` (identique au logo), texte en bleu `#429ab9` et vert `#85c65a`. Le logo se fond sans bordure visible. |
| TM-56 | Image OG : date jour unique                                   | L'image affiche « le 12 mars 2026 » (pas « du 2026-03-12 au 2026-03-12 »).                                                                                                              |
| TM-57 | Image OG : date période                                       | L'image affiche « du 9 au 11 mars 2026 ».                                                                                                                                               |
| TM-58 | Image OG : pictogramme météo visible                          | L'icône de la condition dominante (ex: soleil) est affichée en grand à droite de l'image.                                                                                               |
| TM-59 | OG title jour unique dans les meta tags                       | `og:title` contient « le 12 mars 2026 » pour un appel avec start == end.                                                                                                                |
| TM-60 | Bouton Bluesky dans le bloc de partage                        | Le bloc de partage contient 6 boutons. « Bluesky » ouvre `bsky.app/intent/compose?text=...` dans un nouvel onglet.                                                                      |
| TM-61 | Image OG : flèche Unicode dans les températures               | L'image affiche « 3.9 °C → 13.7 °C » (flèche Unicode, pas `->`) — R44 intégré.                                                                                                          |
| TM-62 | Preview OG Facebook/LinkedIn (opengraph.xyz)                  | Le titre de preview contient la date en français. L'image de preview a le nouveau design (fond clair, icône météo).                                                                     |

### Edge cases critiques (additionnels)

- **Emoji variation selector** — `_WEATHER_ICON_MAP` doit gérer les emojis avec et sans variation selector (U+FE0F). Si le code de `WMO_DESCRIPTIONS` utilise `"☀️"` (avec FE0F), la clé du mapping doit être identique.
- **Icône PNG manquante** — si `src/assets/weather-icons/sun.png` n'existe pas, `_load_weather_icon` retourne `None` et l'image est générée sans icône. Test T-06 (mode fallback) couvre ce cas.
- **Date du 1er du mois** — `format_date_fr("2026-01-01")` doit retourner `"1er janvier 2026"`, pas `"1 janvier 2026"`. Test T-16 vérifie.

---

## 7) Risques techniques

| #   | Risque                                                                       | Probabilité | Impact | Mitigation                                                                                                                                      |
| --- | ---------------------------------------------------------------------------- | ----------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Les crawlers sociaux timeout sur l'image OG (hérité v14)                     | Moyenne     | Moyen  | Cache serveur 7j + Cache-Control 24h (inchangé).                                                                                                |
| 2   | L'injection regex OG casse si le template HTML change (hérité v14)           | Faible      | Élevé  | Tests T-08/T-09/T-10 à chaque modification. Inchangé.                                                                                           |
| 3   | Les icônes PNG ne sont pas déployées (oubli dans le repo ou le build Docker) | Faible      | Moyen  | Le `COPY src/assets /app/src/assets` Dockerfile les inclut. Test T-21 vérifie le rendu avec icône. CI/CD devrait alerter si les tests échouent. |
