# Format « app de Avalon » — version 1

> **Référence structurée des capacités** : la liste exhaustive des primitives
> `App.*` (signature, retour exact, sens de `null`, permission, pièges) est
> décrite en JSON dans [`CAPACITES.json`](CAPACITES.json), servi à côté de ce
> fichier. Ce document-ci donne le contrat d'ensemble ; `CAPACITES.json` est
> la source de vérité machine-lisible de chaque capacité.

Une app tient dans **un seul fichier HTML**. Le fichier contient tout : la
configuration, le code, et — à l'export seulement — les données.

Toute page HTML valide est une app valide. Tout ce qui suit est optionnel :
un fichier sans configuration fonctionne, il perd juste l'identité, la
lignée et la signature.

---

## Les trois zones

### 1. Configuration — dans le `<head>`

```html
<script type="application/json" id="app-Avalon">
{
 "format": 1,
 "id": "ab12cd34",
 "version": 3,
 "nom": "Régime végétarien",
 "icone": "note",
 "consigne": "Réponds par un fichier .html complet…",
 "permissions": [],
 "sources": [],
 "histoire": [],
 "signature": {
  "algo": "ECDSA-P256-SHA256",
  "cle": "<clé publique, SPKI en base64>",
  "valeur": "<signature en base64>",
  "quand": "2026-08-29T10:00:00.000Z"
 }
}
</script>
```

| Champ | Rôle |
|---|---|
| `format` | Version du présent format. Vaut `1`. |
| `id` | Identité stable de l'app. **Ne change jamais**, même si le nom change. |
| `version` | Entier, incrémenté à chaque réécriture. Sert à détecter les retours en arrière. |
| `nom` | Libellé affiché. Peut changer librement. |
| `consigne` | Les règles que doit suivre un modèle qui réécrit ce fichier. **L'app porte ses propres règles.** |
| `icone` | Nom d'une icône d'Avalon (`app`, `carte`, `photo`, `son`, `boussole`, `note`) ou un fragment SVG. Facultatif : Avalon en déduit une des permissions. |
| `permissions` | Capacités demandées au Avalon : `camera`, `microphone`, `geolocation`, `orientation`. Toute autre valeur est ignorée. |
| `sources` | Hôtes d'open data que l'app veut lire via `App.donnees()`. Facultatif, montrés à l'utilisateur à l'import. |
| `fusion` | `true` si les données de l'app se synchronisent automatiquement entre deux appareils présents en même temps (voir « Fusion de données »). Facultatif, absent par défaut. |
| `histoire` | Journal des versions et des demandes. Facultatif. |
| `signature` | Voir plus bas. `null` si non signé. |

### 2. Code — le corps du document

HTML, CSS et JavaScript, dans le même fichier. Aucune ressource externe
n'est garantie disponible.

### 3. Données — avant `</body>`, à l'export uniquement

```html
<script type="application/json" id="donnees-Avalon">{ … }</script>
```

Ce bloc **n'existe que dans un fichier exporté**. À l'import, Avalon le
retire du code et le range séparément. Un fichier qu'on envoie à un modèle
n'en contient jamais.

---

## Contrat d'exécution

L'app s'exécute dans une iframe cloisonnée, sans `allow-same-origin`. Son
origine est opaque. Par conséquent :

- `localStorage`, `sessionStorage` et les cookies **lèvent une exception**
- le réseau n'est pas coupé, mais toute requête soumise au CORS échoue
  (origine `null`) ; la consigne par défaut interdit le réseau — la seule
  lecture distante autorisée est `App.donnees()` (voir plus bas)
- l'app ne voit ni Avalon, ni les autres apps, ni leurs données
- **`getUserMedia` échoue** : une origine opaque ne peut recevoir aucune
  permission, quelle que soit la valeur de l'attribut `allow`. C'est le
  Avalon qui capture, à sa propre origine, et remet le résultat

Avalon injecte deux fonctions, et deux seulement :

```js
const d = await App.lire();   // l'objet enregistré, ou null la première fois
await App.ecrire(objet);      // enregistre ; à appeler après chaque modification
App.fermer();                 // rend la main au Avalon
```

Si l'app déclare les permissions correspondantes, et que l'utilisateur les
a accordées :

```js
const img = await App.photo();      // JPEG en data-URL, déjà réduite, ou null
const s   = await App.son(5);       // { moyenne, pic, secondes }, ou null
const p   = await App.position();   // { lat, lon, precision, altitude }, ou null
const c   = await App.cap();        // { cap, absolu, tangage, roulis }, ou null
await App.viseur(true);             // caméra en fond, l'iframe devient transparente
```

`viseur` place le flux caméra **derrière** l'iframe et rend celle-ci
transparente : l'app dessine son incrustation par-dessus sans jamais
accéder aux pixels. Le fond de la page doit alors être transparent, et le
viseur doit être coupé en quittant ce mode.

`cap` renvoie la dernière lecture de la boussole, **lissée** sur les trois
angles avec un gain adaptatif : la gigue au repos est amortie, un vrai
mouvement est suivi sans retard. Les valeurs non filtrées restent
disponibles dans `brut`.

```js
await App.fond({lat, lon, cap, site, opacite, relief:true, oeil:true});
await App.fond({lat, lon, zoom, inclinaison, cap, opacite});   // vue survol
await App.fond({actif:false});
```

### Ligne d'horizon (skyline)

`App.skyline()` renvoie la **silhouette du relief** autour d'une position
(comme PeakFinder). C'est l'hôte qui télécharge les tuiles d'altitude,
calcule la polyligne et renvoie le résultat ; l'app ne fait que dessiner.

```js
const s = await App.skyline({ lat, lon, altitude, rayon });
// s = { actif, points, lieux, rayon }
//   points : [[azimut, élévation], …]  (720 azimuts, 0 = nord)
//   lieux  : [{ nom, kind:'pic'|'ville'|'ile', az, theta, distance }]
//   actif:false → raison/detail expliquent l'échec
```

`points` est la ligne d'horizon : l'élévation angulaire maximale du terrain
pour chaque azimut, courbure terrestre et réfraction comprises. `lieux`
sont les sommets, villes et îles nommés (OpenStreetMap) dans le rayon,
déjà convertis en azimut/élévation/distance. L'app projette `(az, theta)`
à l'écran selon son cap et son inclinaison, puis dessine la silhouette et
les libellés en Canvas 2D. À recalculer seulement quand on se déplace.

```js
await App.veille(true);   // empêche l'écran de s'éteindre tant que l'app est visible
```

Sans ce verrou, l'extinction de l'écran suspend tout : animations arrêtées,
minuteurs bridés, capteurs muets. Le verrou n'existe que tant que la page
est visible ; Avalon le reprend automatiquement au retour. Il n'y a
aucune exécution en arrière-plan.

```js
App.surVisibilite(function(v){ ... });  // v.visible true/false : Avalon au
                                       // premier plan ou masqué
```

Quand Avalon passe en arrière-plan (une autre app devant, écran allumé),
l'app reçoit `{ visible:false }` et peut suspendre ce qui ne sert plus
(animations, dessins). Au retour, `{ visible:true }`. Le micro en cours
d'enregistrement continue de lui-même : ne le coupez pas sur cet évènement.

```js
const texte = await App.qrLire();      // scanne un QR/code-barres → le texte, ou null
await App.qrAfficher(texte);           // affiche un QR (partage de données), se résout à la fermeture
const r    = await App.enregistrer();  // { audio, type, secondes }, ou null
```

`qrLire` ouvre la caméra et lit un QR (ou un code-barres) en continu
jusqu'à en trouver un : il renvoie le texte décodé, que l'app interprète
comme elle veut (souvent du JSON). `qrAfficher` affiche un QR plein écran
encodant `texte` — l'autre moitié du partage de données : un appareil scanne
ce que l'autre montre. La taille d'un QR est limitée à quelques kilo-octets.

`enregistrer` ouvre le micro et enregistre un mémo vocal jusqu'à ce que
l'utilisateur arrête. Il rend `{ audio, type, secondes }` où `audio` est une
URL de données (réjouable, stockable via `App.ecrire`) et `type` dépend du
navigateur (`audio/webm` sur Chrome, `audio/mp4` sur Safari).

### Lecture de données publiques (open data)

Une app peut lire des données publiques sur le réseau, **en lecture seule**
et par l'intermédiaire d'Avalon (elle n'a jamais d'accès réseau direct) :

```js
const texte = await App.donnees('https://api.open-meteo.com/v1/forecast?latitude=48');
const objet = await App.donnees('https://api.open-meteo.com/v1/forecast?latitude=48', { json:true });
const tuile = await App.donnees('https://tiles.openfreemap.org/planet/14/8000/6000.pbf', { binaire:true }); // ArrayBuffer
```

Avalon n'accepte que `GET`, sur des hôtes **en liste blanche** (open data
sans clé : OpenStreetMap, OpenFreeMap, Géoplateforme IGN, data.gouv.fr,
Open-Meteo, SHOM, Panoramax, Wikidata, Open Food Facts…) ou déclarés par
l'app dans `sources` (montrés à l'utilisateur à l'import). Pas de cookie,
pas d'en-tête, plafond de 8 Mo, réseau local interdit. En cas de refus,
l'appel rejette. **Aucune clé API n'est possible** : un secret dans un
fichier client est public — seules les sources sans clé fonctionnent.

### Communication en direct

Deux apps peuvent échanger des messages en temps réel, d'appareil à appareil,
sans serveur (le code de session chiffre la liaison) :

```js
const session = await App.appeler();   // crée une session → { code } à dicter
await App.rejoindre(code);             // rejoint la session de l'autre appareil
await App.dire(donnees);               // envoie un message (texte ou objet)
await App.raccrocher();                // quitte la session
const suite = await App.reprendre();   // au retour dans l'app, reprend la session
App.surMessage(function(d){ ... });    // reçoit chaque message entrant
App.surEtat(function(e){ ... });       // e.connecte passe à true/false
```

`appeler` ouvre une session et renvoie `{ code }` (six caractères) à dicter à
l'autre appareil, qui le saisit via `rejoindre`. `dire` envoie un message,
reçu sur l'autre appareil par le rappel `surMessage`. `surEtat` signale
l'arrivée (`{connecte:true}`) et le départ (`{connecte:false}`) de l'autre
appareil. `raccrocher` ferme la session. Rien n'est stocké : seuls les
appareils présents reçoivent les messages (coprésence obligatoire). `appeler`
et `rejoindre` rendent `null` si la bibliothèque pair-à-pair n'a pas pu
charger.

La session **survit à la fermeture de l'app** : si l'app mémorise le `code`
(avec `App.ecrire`) et appelle `App.reprendre()` au chargement, elle retrouve
la session encore ouverte (`{ code, connecte }`, ou `null` si aucune). Les
messages reçus **pendant que l'app est fermée** sont mis de côté par Avalon et
délivrés d'un coup au retour, dans l'ordre, via le rappel `surMessage`. Tant que
la session est connectée, une **pastille verte** apparaît sur l'icône de l'app
dans l'accueil d'Avalon.

### Appels audio et vidéo

Sur une session déjà ouverte, une app peut lancer un appel :

```js
await App.appelerAudio();               // appel vocal (Avalon capte le micro)
await App.appelerVideo();               // appel vidéo (Avalon capte micro + caméra)
App.surAppel(function(e){ ... });       // e.genre = entrant / connecte / termine / refuse
```

Avalon capture le micro et la caméra, transporte le flux et le fait
entendre/voir — l'app ne voit jamais le flux. La **vidéo s'affiche en plein
écran dans Avalon**, par-dessus l'iframe ; l'app garde le contrôle
(`raccrocher`). L'appel **entrant** affiche un écran « Accepter / Refuser » géré
par Avalon, qui prévient l'app via `surAppel({genre:'entrant'})`. `raccrocher`
ferme l'appel et la session.

### Fusion de données (deux appareils présents)

Si l'app porte `fusion:true` dans sa configuration, Avalon synchronise
**automatiquement** ses données entre deux appareils qui partagent la même
session **en même temps** (coprésence) :

```js
App.surDonnees(function(){ ... });   // relis (App.lire) et redessine
```

À chaque `App.ecrire()`, Avalon diffuse l'état courant à l'autre appareil ;
à la réception, il fusionne et prévient l'app via `surDonnees`. L'app
relit alors ses données avec `App.lire()` et redessine — elle ne doit **pas**
renvoyer ses données via `App.dire()` (la fusion s'en charge).

La fusion est **structurelle, pas sémantique** : chaque valeur porte un
horodatage, « la plus récente gagne » champ par champ ; les tableaux sont
concaténés et dédoublonnés. Elle convient à ce qui s'accumule (journal,
carnet, relevés), pas à un compteur partagé édité en même temps par les
deux mains. Il n'y a **aucun relais** : les deux appareils doivent être en
ligne ensemble ; rien ne transite ni ne survit à leur absence.

`fond` affiche une carte 3D avec relief derrière l'iframe, rendue par le
Avalon (MapLibre et OpenFreeMap pour les fonds, tuiles d'altitude AWS Open
Data pour le relief — aucune clé, aucun compte).

Avec `oeil:true`, la caméra est placée **au point où se trouve
l'utilisateur, à l'altitude du sol plus deux mètres**, orientée par `cap` et
par `site` (angle du regard, 0 = horizontale, négatif vers le bas). La vue
de la carte coïncide alors avec celle de l'appareil photo. L'appel renvoie
`oeil:{altitude, sol, tangage}` ; `sol` vaut `null` si le relief n'est pas
encore chargé, auquel cas l'altitude retombe sur deux mètres. `opacite` va de 0 à 1
et permet de fondre la carte avec ce qu'il y a dessous. Le rendu vient du
réseau : hors ligne, l'appel renvoie `{actif:false, raison}` et l'app doit
prévoir son propre fond. L'app n'accède jamais aux tuiles ni au contexte
WebGL.

Un fragment SVG fourni dans `icone` est nettoyé par Avalon : scripts et
gestionnaires d'évènements sont retirés avant affichage.

Ces deux appels ouvrent l'interface d'Avalon et attendent l'utilisateur :
ils n'ont pas de délai d'expiration, et rendent `null` en cas d'annulation
ou de refus. L'app ne voit jamais le flux, seulement le résultat validé.
La réduction des images est décidée par Avalon, pas par l'app.

Il n'y a pas de bouton « sauvegarder » : on écrit à chaque modification.

---

## Migration des données

Le code et les données suivent deux chemins séparés. Une app réécrite
retrouve donc les données de la version précédente.

**Toute app qui change la forme de ses données doit porter sa migration.**

```js
const FORMAT = 3;
function migrer(d){
  return {
    version: FORMAT,
    journal: (d.journal||[]).map(r => ({ ...r, moment: r.moment || 'Non précisé' })),
    recettes: d.recettes || []
  };
}
```

Appelée au chargement, avant tout affichage. Elle ne doit jamais perdre une
donnée saisie. Avalon sauvegarde l'état précédent avant toute mise à
jour, mais c'est un filet, pas une excuse.

---

## Identité et lignée

`id` identifie l'app. Deux fichiers de même `id` sont deux versions de la
même app ; deux fichiers de même `nom` mais d'`id` différents sont deux apps
distinctes.

Un Avalon qui reçoit une `version` inférieure à celle qu'il détient doit
avertir avant d'écraser.

Pour compatibilité, un fichier sans bloc de configuration est lu par repli
via `<meta name="app-id">` et `<meta name="version">`. Avalon lui ajoute
une configuration au premier import.

---

## Signature

ECDSA sur la courbe P-256, empreinte SHA-256. La clé privée est générée
dans le navigateur et créée **non exportable** : elle n'en sort jamais.

Ce qui est signé, dans cet ordre, séparé par des octets nuls :

```
format ⋄ id ⋄ version ⋄ nom ⋄ consigne ⋄ permissions ⋄ code nu
```

où *code nu* est le fichier privé de son bloc de configuration **et** de son
bloc de données. Ni la signature ni les données n'entrent dans le calcul.

Un Avalon doit distinguer quatre états : **absente**, **valide**,
**invalide**, et valide mais **d'une clé inconnue**. Une signature invalide
ou une provenance externe non vérifiée exige un accord explicite avant
exécution.

Une signature prouve la continuité, pas l'identité. Savoir à qui appartient
une clé relève de l'usage répété, de l'échange direct, ou d'un annuaire —
pas du format.

---

## Partage par lien

Deux formes, sur la page d'Avalon :

```
…/#code=<gzip, base64url>      l'app entière tient dans le lien
…/#app=<adresse https>          Avalon va chercher le fichier
```

Limite pratique : environ 7 000 caractères d'URL. Une app de 8,5 Ko
compressée donne un lien de 4,3 Ko — la plupart des apps simples passent.

Un lien ne transporte jamais de données. Celui qui l'ouvre reçoit une app
vide.

Avalon doit afficher la provenance et l'état de la signature, puis
attendre un accord, avant d'installer quoi que ce soit.

---

## Créer une app

Avalon ne fabrique pas un prompt mais un **fichier gabarit** : une app
vide mais valide, dont le bloc de configuration porte déjà un identifiant
neuf, les permissions demandées et les règles d'exécution, et dont le code
appelle correctement `App.lire()` et `App.ecrire()`.

Ce gabarit s'envoie en pièce jointe et s'importe tel quel. La création et la
modification suivent donc exactement le même chemin.

---

## Vérifier qu'un fichier est conforme

1. Il s'ouvre seul dans un navigateur sans erreur de syntaxe
2. Sans Avalon autour, il signale l'absence d'`App` au lieu de planter
3. `id` est stable entre deux versions, `version` est croissante
4. Une migration existe si la forme des données a changé
5. Aucun appel à `localStorage`, aux cookies, ni requête réseau directe (seul `App.donnees()` lit de l'open data, via Avalon)
