Compare commits

...

5 Commits

Author SHA1 Message Date
Thomas Fransolet
ad73aefe38 Kanban régénéré : 43 chantiers planifiés
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:29:16 +02:00
Thomas Fransolet
125813c34e Crédits Studio : un préavis, pas un nouveau plafond (décision 27)
La limite demandée existe déjà deux fois — le solde prépayé est le plafond, et
StudioPerUserDailyCap borne la journée d'un utilisateur. Ce qui manque est
l'alerte : aujourd'hui le premier signal reçu est une génération qui échoue en
InsufficientCredits, et l'expiration à 12 mois fait s'évaporer un solde sans
prévenir. Seuil configurable par instance, mail au franchissement et rappel
d'expiration à J-30.

Carte kanban 282.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:29:16 +02:00
Thomas Fransolet
fb1d15d0b3 Tags NFC : analyse du troisième déclencheur physique
Deux recadrages par rapport à la demande initiale : le déclencheur n'est pas un
POI (qui est un point sur un GLB) mais une Section, au même endroit que le
beacon ; et l'indirection code → section n'a aucun équivalent existant à
réutiliser — en v1 le tag porte la même URL que le QR, donc zéro entité, zéro
endpoint.

Le vrai coût est ailleurs : ni Universal Links ni App Links n'existent sur nos
domaines, et ce lot-là profite autant au QR, qu'un appareil photo natif n'ouvre
pas dans l'app aujourd'hui. Dépend du déploiement de visitapp-web pour le
fallback sans app installée.

Carte kanban 265.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:29:16 +02:00
Thomas Fransolet
a227fc9d00 VR : quatre écarts entre l'onglet du manager et le casque
Relevé dans le code le 2026-09-16. Même cause pour les quatre : XR-2 a réutilisé
AppConfigurationLinkScreen tel quel, et cet écran sert quatre canaux qui n'ont
pas les mêmes réglages. Le sélecteur bento ne pilote rien en VR (les spans sont
portés par le lien de configuration, pas par les sections), la notification
annonce « application mobile », la popup d'édition d'un casque est celle du
kiosk avec la moitié des champs morte, et un bento VR suppose des spans par
section jusque dans Unity.

Plan dans v2/vr-menu-bento-plan.md, carte kanban 340.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:18:40 +02:00
Thomas Fransolet
a588c53549 Studio : ordre de passage du plan de test et socle d'upload
Le plan de test gagne un § 0bis : le lot 0 a réécrit tout le chemin d'upload
(URL V4 signée puis POST /ingest) et rien n'a jamais été déposé depuis un
navigateur, alors que les § 1 à 5 en dépendent tous. Ajout aussi de l'ordre de
passage en quatre séances, de la façon de reporter un échec, et des résultats
déjà obtenus sur le § 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:18:40 +02:00
10 changed files with 561 additions and 6 deletions

View File

@ -178,6 +178,7 @@ Dix-neuf chantiers de canaux et d'IA, chacun avec son plan détaillé. État au
- ✅ **`XR-3` est clos le 2026-09-12** : le JSON d'export est un **contrat versionné** (`exportVersion: 1`, `generatedAt`, règles de compatibilité écrites sur le DTO), et la question multilingue est tranchée par le code lui-même.
- ✅ **`XR-5` est clos aussi** : **pousser une configuration vers le casque sans MQTT** — la réponse au battement porte la configuration assignée, le casque compare et recharge en revenant au menu, sans redémarrage. Coût : trois minutes de latence au pire. Ce que ça évite : une connexion permanente sur une borne et une bibliothèque MQTT dans le build IL2CPP. Le publish serveur existe déjà si un jour trois minutes sont trop. Plus l'**alerte « casque muet »** après une heure sans battement — elle corrige un mensonge, la pastille « connecté » venant du dernier battement reçu.
- ➡️ **Prochain travail sur le canal XR** : **jouer le §25 du [plan de test](test-plan.md)** — 11 blocs, **135 cas**. Le §25.7 (déclarer une 360 dans le manager) et le §25.9.7 (appairer une nouvelle tablette) se jouent **sans casque** ; le reste demande le Quest. **Le POC est écrit en entier** : rien de E2 à E10 n'a jamais tourné, et c'est la seule chose qui manque.
- 🔴 **Quatre écarts entre l'onglet VR du manager et le casque, relevés dans le code le 2026-09-16** — [v2/vr-menu-bento-plan.md](v2/vr-menu-bento-plan.md), carte kanban 340. Ils ont tous la même cause : `XR-2` a réutilisé `AppConfigurationLinkScreen` tel quel (« zéro code neuf »), et cet écran sert quatre canaux qui n'ont pas les mêmes réglages. **Un** : le **sélecteur de forme bento ne pilote rien en VR** — les spans sont portés par le *lien de configuration* (`AppConfigurationLink.cs:35`, « Specific Mobile & Web ») et n'alimentent que l'écran « choisis ta configuration », qui **n'existe pas dans le casque** : un casque est appairé à une seule configuration, et le menu affiche les *sections*. `ConfigurationExport` n'a aucun champ span. **Deux** : la notification annonce « application mobile » — le paramètre `appUpdatedLabel` existe et n'est pas passé, ~15 min. **Trois** : la popup d'édition d'un casque est celle du kiosk, et **la moitié de ses champs est morte en VR** (couleurs, loader, arrondis, place des sections, heure, date — `grep` sur `Assets/Scripts` : zéro occurrence de chacun). **Quatre**, qui découle du premier : pour qu'un bento ait un sens en VR il faut des **spans par section** (backend + manager), puis un placement dense mappé sur l'arc côté Unity — l'arc est conservé, seul le calcul des positions change. Ordre : notification → popup → spans → Unity ; mini-prompts en fin de plan.
- **Abandonné le 2026-09-02** — talking head : son lipsync reposait sur `enable_time_pointing` de Google Cloud TTS, or le code livré tourne sur Gemini TTS. Le plan était sans fondation, pas « à faire ».
→ **Détail complet, la table des 19 chantiers : [status/roadmap-canaux.md](status/roadmap-canaux.md)**

View File

@ -462,7 +462,7 @@
<div class="stat"><span class="n n-info">1</span><span class="k">Migration v3</span></div>
<div class="stat"><span class="n n-warn">2</span><span class="k">Bugs ouverts</span></div>
<div class="stat"><span class="n">12</span><span class="k">À tester</span></div>
<div class="stat"><span class="n">40</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n">43</span><span class="k">Planifié</span></div>
<div class="stat"><span class="n n-gate">9</span><span class="k">Bascule prod</span></div>
<div class="stat"><span class="n n-good">66</span><span class="k">Fait récemment</span></div>
</section>
@ -706,7 +706,7 @@
<!-- PLANIFIÉ -->
<section class="col" style="--stripe: var(--ink-3)">
<div class="col-head"><h2>Planifié</h2><span class="count">40</span></div>
<div class="col-head"><h2>Planifié</h2><span class="count">43</span></div>
<div class="stack">
<article class="card" data-area="visitapp" data-horizon="v1">
@ -975,6 +975,17 @@
<span class="src">todo-features.md — AR</span>
</article>
<article class="card" data-area="backend manager visitapp" data-horizon="v2">
<div class="card-meta"><span class="tag">visitapp</span><span class="tag">matériel</span><span class="flag f-warn">Nouveau 16/09 · le gros du coût est les Universal/App Links, partagés avec le QR</span></div>
<h3>Tags NFC comme déclencheurs de section</h3>
<p><strong>Troisième déclencheur physique, à côté du QR et du beacon.</strong> Analyse et faisabilité : <a href="../v2/nfc-triggers-plan.md">v2/nfc-triggers-plan.md</a>.</p>
<p>⚠️ <strong>Deux recadrages par rapport à la formulation initiale.</strong> (1) Le déclencheur n'est pas un « POI » : dans MyInfoMate le POI est un point posé sur un GLB (VR/3D, <code>immersif-frontiere-plan.md</code>). Le déclencheur physique est une <strong>Section</strong><code>Section.IsBeacon</code> / <code>BeaconId</code>, lu par <code>geo_beacon_trigger_service.dart:177</code>. Le tag NFC se branche au même endroit que le beacon. (2) L'URL <code>/t/{instanceId}/{code}</code> et sa table de mapping <strong>n'ont pas d'équivalent existant à réutiliser</strong> : les QR d'aujourd'hui portent l'id de section directement dans l'URL (trois formats coexistants, <code>ScannerDialog.dart:31-38</code>), il n'y a aucun endpoint de résolution <code>code → section</code>. <strong>v1 = le tag porte la même URL que le QR</strong>, donc zéro entité, zéro endpoint, zéro CRUD ; l'indirection ne se justifie que si un client demande un parc de tags réassignables — et elle vaut bien moins qu'en QR, puisque réécrire un NTAG prend deux secondes alors qu'un QR se réimprime.</p>
<p><strong>Le vrai coût est ailleurs : les Universal Links (iOS) et App Links (Android) n'existent pas.</strong> Aucun <code>assetlinks.json</code> ni <code>apple-app-site-association</code> sur nos domaines (le seul du workspace est dans <code>OpenGlasses</code>, qui n'est pas à nous), et <code>app_links</code> n'est branché que sur le retour de deep link Meta AI (<code>main.dart:69</code>). ✅ <strong>Ce lot bénéficie autant au QR</strong> : aujourd'hui un QR scanné par l'appareil photo natif n'ouvre pas l'app.</p>
<p><strong>Dépendance</strong> : le fallback web d'un tag scanné sans app installée, c'est <code>visitapp-web</code><strong>pas encore déployé</strong> (carte « Déployer le visiteur web »). Sans lui, un tag scanné par un visiteur sans l'app tombe dans le vide.</p>
<p><strong>Ce qui ne marchera pas, à dire au commercial</strong> : pas de lecture NFC depuis <code>visitapp-web</code> (WebNFC est Chrome/Android uniquement, jamais Safari), et l'écriture de tags depuis iOS est trop contrainte — <strong>l'écran admin de programmation sera Android-only</strong>. En contrepartie, iPhone XS+ lit un tag NDEF <strong>sans aucune app</strong>, ce qui est l'argument réel du canal.</p>
<span class="src">nfc-triggers-plan.md · ScannerDialog.dart:31-38 · Section.cs:57 · main.dart:69</span>
</article>
<article class="card" data-area="manager" data-horizon="v2">
<div class="card-meta"><span class="tag">produit</span><span class="flag f-warn">V2 · nouveau 12/08</span></div>
<h3>Écran SuperAdmin — add-on IA &amp; canaux d'une instance</h3>
@ -1000,6 +1011,17 @@
<span class="src">v2/studio-plan.md — §8 (exécutable lots 0-4), §9 (catalogue v0), décisions 22-23</span>
</article>
<article class="card" data-area="backend manager" data-horizon="v2">
<div class="card-meta"><span class="tag">studio</span><span class="tag">crédits</span><span class="flag f-warn">Nouveau 16/09 · le plafond dur a été retiré exprès (décision 22)</span></div>
<h3>Seuil d'alerte sur le solde de crédits Studio (pas un nouveau plafond)</h3>
<p><strong>La limite demandée existe déjà, deux fois.</strong> Avec des crédits prépayés (décision 17), <strong>le solde <em>est</em> le plafond</strong> — c'est exactement pour ça que la décision 22 a retiré le plafond dur organisation le 13/09. Et le garde-fou contre le stagiaire qui vide le budget en une après-midi, c'est <code>Instance.StudioPerUserDailyCap</code>, déjà appliqué dans <code>StudioCreditService.ReserveAsync</code> (<code>CreditError.DailyCapReached</code>). Rien à ajouter de ce côté : <strong>ne pas recréer un plafond organisation.</strong></p>
<p>⚠️ <strong>Le modèle n'est pas celui de Google AI Studio ni de fal.ai.</strong> Là-bas on est en <strong>postpayé</strong> : sans budget alert ni hard cap, on découvre la facture. Ici le client a payé avant ; il ne peut pas se faire surprendre par une dépense, seulement par une <strong>indisponibilité</strong> — et ça, ça ne se règle pas avec un plafond, mais avec un préavis.</p>
<p><strong>Ce qui manque vraiment : le préavis.</strong> Aujourd'hui aucune alerte n'existe. <code>studio_credits_section.dart</code> affiche disponible / solde / réservé / expiration, mais il faut aller ouvrir l'écran Facturation pour les voir ; une génération qui échoue en <code>InsufficientCredits</code> est le premier signal reçu par le conservateur. Et surtout, <strong>l'expiration à 12 mois (décision 17) est le vrai piège</strong> : un solde qui s'évapore sans prévenir, sur une institution publique, c'est un litige — pas une notification manquée.</p>
<p><strong>À faire</strong> : <code>Instance.StudioCreditsAlertThreshold</code> (en crédits, 0 = désactivé) + <code>StudioCreditsAlertSentAt</code> pour ne pas renvoyer le mail à chaque génération ; déclenchement dans <code>StudioCreditService.Record</code> quand le solde passe sous le seuil, et dans <code>ExpireDueAsync</code> (déjà un job quotidien) pour un rappel d'expiration <strong>à J-30</strong>. Envoi par <code>IEmailService</code> / <code>ResendEmailService</code>, déjà en place. Côté manager-app : un champ dans l'écran Facturation et un bandeau dans le popover de la jauge du pied de menu, i18n FR/EN/NL.</p>
<p><strong>Petit lot</strong> : 2 colonnes, un hook, un template de mail, un champ d'UI. À faire <em>après</em> la fixation des prix (décision 16, fin du lot 3) — un seuil en crédits n'a pas de valeur par défaut tant que l'unité de crédit n'est pas arrêtée.</p>
<span class="src">studio-plan.md décisions 17 et 22 · StudioCreditService.cs · studio_credits_section.dart</span>
</article>
<article class="card" data-area="manager" data-horizon="v2">
<div class="card-meta"><span class="tag">manager-app</span><span class="tag">visitapp</span><span class="tag">tablet</span><span class="tag">vr</span><span class="flag f-good">Décision arrêtée, à exécuter après la bascule</span></div>
<h3>Loader animé — presets paramétriques, pas de format de fichier</h3>
@ -1192,6 +1214,25 @@
<span class="src">myinfomate-landing/audit-seo-geo.md action 19 · DOCS/v2/vr-quest-unity-plan.md · DOCS/rayban-meta-integration.md</span>
</article>
<article class="card" data-area="doc" data-horizon="v2">
<div class="card-meta"><span class="tag">XR</span><span class="tag">manager-app</span><span class="tag">manager-service</span><span class="flag f-warn">V2 · 4 écarts entre l'onglet VR et le casque</span></div>
<h3>Le menu VR n'est pas ce que le manager laisse configurer</h3>
<p>L'onglet <strong>Main / VR</strong> réutilise <code>AppConfigurationLinkScreen</code>, l'écran de configuration partagé avec le mobile, le web et le kiosk (choix assumé de <code>XR-2</code> : « zéro code neuf »). Il en hérite <strong>quatre comportements qui ne correspondent à rien en VR</strong>, relevés dans le code le 2026-09-16.</p>
<p>🔴 <strong>Le sélecteur de forme bento ne pilote rien dans le casque.</strong> Les spans sont portés par le <strong>lien de configuration</strong> (<code>AppConfigurationLink.cs:35</code>, commentaire d'origine : « Specific Mobile &amp; Web ») et ne servent qu'à l'écran « choisis ta configuration » du mobile et du web. Or un casque est appairé à <strong>une seule</strong> configuration : <strong>cet écran n'existe pas en VR</strong>. Le menu affiche les <em>sections</em> racines, toutes à la même taille — <code>grep -r "Span" Assets/Scripts</code> ne rend rien, <code>ConfigurationExport</code> n'a aucun champ span.</p>
<p>➡️ <strong>Décision : le bento VR doit porter sur les sections</strong>, pas sur les configurations. Sans ça les boutons resteront décoratifs quoi qu'on fasse. Ça demande <code>GridColSpan</code>/<code>GridRowSpan</code> sur <code>Section</code> (entité, DTO, migration, client API édité à la main) — l'export les transporte ensuite gratuitement — puis un placement dense par spans côté Unity. <strong>L'arc est conservé</strong> : ce n'est pas un défaut de rendu, un mur plat se regarde de biais sur ses bords. La grille est calculée en cellules, puis chaque cellule est <em>mappée</em> sur un angle et une hauteur. Cible <strong>6 colonnes × 2 rangées</strong> — le confort visuel en VR c'est ±60°, ce qui donne naturellement le format paysage proche du rendu web.</p>
<p>🟠 <strong>La notification annonce la mauvaise app.</strong> Modifier un contenu VR affiche « application mobile mise à jour ». Le paramètre existe déjà (<code>appUpdatedLabel</code>, <code>app_configuration_link_screen.dart:144</code>) et n'est simplement pas passé par <code>vr_screen.dart</code>. Une clé i18n et quatre lignes — <strong>~15 minutes</strong>.</p>
<p>🟠 <strong>La popup d'édition d'un casque est celle du kiosk.</strong> Largeur figée à 580, une colonne scrollée, et deux boutons dont les libellés « Annuler »/« Changer » sont <strong>en dur</strong>. Mais le vrai problème n'est pas la mise en page : <strong>la moitié des champs est morte en VR</strong> — couleur principale, couleur de fond, loader, pourcentage d'arrondis, place des sections, fond d'image, heure, date. <code>grep -r</code> sur <code>Assets/Scripts</code> : <strong>zéro occurrence</strong> de chacun. Un casque n'a ni écran de chargement 2D ni horloge d'accueil. À remplacer par une popup VR dédiée, paysage deux colonnes.</p>
<p>⚠️ <strong>Ce que ce chantier dit du reste</strong> : les quatre écarts ont la même cause — un écran partagé par quatre canaux qui n'ont pas les mêmes réglages. Le plan recommande d'<strong>en sortir</strong> pour la VR plutôt que d'y ajouter un quatrième cas particulier.</p>
<p><strong>Ordre</strong> : la notification (indépendante, 15 min) → la popup (indépendante) → les spans backend + manager → le rendu Unity (rien de visible avant). Quatre mini-prompts de lancement sont en fin de plan.</p>
<span class="src">v2/vr-menu-bento-plan.md · relevé dans le code le 2026-09-16</span>
</article>
</div>
</section>

View File

@ -0,0 +1,13 @@
---
title: Tags NFC comme déclencheurs de section
area: backend manager visitapp
horizon: v2
tags: visitapp, matériel
flag: warn | Nouveau 16/09 · le gros du coût est les Universal/App Links, partagés avec le QR
src: nfc-triggers-plan.md · ScannerDialog.dart:31-38 · Section.cs:57 · main.dart:69
---
<p><strong>Troisième déclencheur physique, à côté du QR et du beacon.</strong> Analyse et faisabilité : <a href="../v2/nfc-triggers-plan.md">v2/nfc-triggers-plan.md</a>.</p>
<p>⚠️ <strong>Deux recadrages par rapport à la formulation initiale.</strong> (1) Le déclencheur n'est pas un « POI » : dans MyInfoMate le POI est un point posé sur un GLB (VR/3D, <code>immersif-frontiere-plan.md</code>). Le déclencheur physique est une <strong>Section</strong><code>Section.IsBeacon</code> / <code>BeaconId</code>, lu par <code>geo_beacon_trigger_service.dart:177</code>. Le tag NFC se branche au même endroit que le beacon. (2) L'URL <code>/t/{instanceId}/{code}</code> et sa table de mapping <strong>n'ont pas d'équivalent existant à réutiliser</strong> : les QR d'aujourd'hui portent l'id de section directement dans l'URL (trois formats coexistants, <code>ScannerDialog.dart:31-38</code>), il n'y a aucun endpoint de résolution <code>code → section</code>. <strong>v1 = le tag porte la même URL que le QR</strong>, donc zéro entité, zéro endpoint, zéro CRUD ; l'indirection ne se justifie que si un client demande un parc de tags réassignables — et elle vaut bien moins qu'en QR, puisque réécrire un NTAG prend deux secondes alors qu'un QR se réimprime.</p>
<p><strong>Le vrai coût est ailleurs : les Universal Links (iOS) et App Links (Android) n'existent pas.</strong> Aucun <code>assetlinks.json</code> ni <code>apple-app-site-association</code> sur nos domaines (le seul du workspace est dans <code>OpenGlasses</code>, qui n'est pas à nous), et <code>app_links</code> n'est branché que sur le retour de deep link Meta AI (<code>main.dart:69</code>). ✅ <strong>Ce lot bénéficie autant au QR</strong> : aujourd'hui un QR scanné par l'appareil photo natif n'ouvre pas l'app.</p>
<p><strong>Dépendance</strong> : le fallback web d'un tag scanné sans app installée, c'est <code>visitapp-web</code><strong>pas encore déployé</strong> (carte « Déployer le visiteur web »). Sans lui, un tag scanné par un visiteur sans l'app tombe dans le vide.</p>
<p><strong>Ce qui ne marchera pas, à dire au commercial</strong> : pas de lecture NFC depuis <code>visitapp-web</code> (WebNFC est Chrome/Android uniquement, jamais Safari), et l'écriture de tags depuis iOS est trop contrainte — <strong>l'écran admin de programmation sera Android-only</strong>. En contrepartie, iPhone XS+ lit un tag NDEF <strong>sans aucune app</strong>, ce qui est l'argument réel du canal.</p>

View File

@ -0,0 +1,13 @@
---
title: Seuil d'alerte sur le solde de crédits Studio (pas un nouveau plafond)
area: backend manager
horizon: v2
tags: studio, crédits
flag: warn | Nouveau 16/09 · le plafond dur a été retiré exprès (décision 22)
src: studio-plan.md décisions 17 et 22 · StudioCreditService.cs · studio_credits_section.dart
---
<p><strong>La limite demandée existe déjà, deux fois.</strong> Avec des crédits prépayés (décision 17), <strong>le solde <em>est</em> le plafond</strong> — c'est exactement pour ça que la décision 22 a retiré le plafond dur organisation le 13/09. Et le garde-fou contre le stagiaire qui vide le budget en une après-midi, c'est <code>Instance.StudioPerUserDailyCap</code>, déjà appliqué dans <code>StudioCreditService.ReserveAsync</code> (<code>CreditError.DailyCapReached</code>). Rien à ajouter de ce côté : <strong>ne pas recréer un plafond organisation.</strong></p>
<p>⚠️ <strong>Le modèle n'est pas celui de Google AI Studio ni de fal.ai.</strong> Là-bas on est en <strong>postpayé</strong> : sans budget alert ni hard cap, on découvre la facture. Ici le client a payé avant ; il ne peut pas se faire surprendre par une dépense, seulement par une <strong>indisponibilité</strong> — et ça, ça ne se règle pas avec un plafond, mais avec un préavis.</p>
<p><strong>Ce qui manque vraiment : le préavis.</strong> Aujourd'hui aucune alerte n'existe. <code>studio_credits_section.dart</code> affiche disponible / solde / réservé / expiration, mais il faut aller ouvrir l'écran Facturation pour les voir ; une génération qui échoue en <code>InsufficientCredits</code> est le premier signal reçu par le conservateur. Et surtout, <strong>l'expiration à 12 mois (décision 17) est le vrai piège</strong> : un solde qui s'évapore sans prévenir, sur une institution publique, c'est un litige — pas une notification manquée.</p>
<p><strong>À faire</strong> : <code>Instance.StudioCreditsAlertThreshold</code> (en crédits, 0 = désactivé) + <code>StudioCreditsAlertSentAt</code> pour ne pas renvoyer le mail à chaque génération ; déclenchement dans <code>StudioCreditService.Record</code> quand le solde passe sous le seuil, et dans <code>ExpireDueAsync</code> (déjà un job quotidien) pour un rappel d'expiration <strong>à J-30</strong>. Envoi par <code>IEmailService</code> / <code>ResendEmailService</code>, déjà en place. Côté manager-app : un champ dans l'écran Facturation et un bandeau dans le popover de la jauge du pied de menu, i18n FR/EN/NL.</p>
<p><strong>Petit lot</strong> : 2 colonnes, un hook, un template de mail, un champ d'UI. À faire <em>après</em> la fixation des prix (décision 16, fin du lot 3) — un seuil en crédits n'a pas de valeur par défaut tant que l'unité de crédit n'est pas arrêtée.</p>

View File

@ -0,0 +1,21 @@
---
title: Le menu VR n'est pas ce que le manager laisse configurer
area: doc
horizon: v2
tags: XR, manager-app, manager-service
flag: warn | V2 · 4 écarts entre l'onglet VR et le casque
src: v2/vr-menu-bento-plan.md · relevé dans le code le 2026-09-16
---
<p>L'onglet <strong>Main / VR</strong> réutilise <code>AppConfigurationLinkScreen</code>, l'écran de configuration partagé avec le mobile, le web et le kiosk (choix assumé de <code>XR-2</code> : « zéro code neuf »). Il en hérite <strong>quatre comportements qui ne correspondent à rien en VR</strong>, relevés dans le code le 2026-09-16.</p>
<p>🔴 <strong>Le sélecteur de forme bento ne pilote rien dans le casque.</strong> Les spans sont portés par le <strong>lien de configuration</strong> (<code>AppConfigurationLink.cs:35</code>, commentaire d'origine : « Specific Mobile &amp; Web ») et ne servent qu'à l'écran « choisis ta configuration » du mobile et du web. Or un casque est appairé à <strong>une seule</strong> configuration : <strong>cet écran n'existe pas en VR</strong>. Le menu affiche les <em>sections</em> racines, toutes à la même taille — <code>grep -r "Span" Assets/Scripts</code> ne rend rien, <code>ConfigurationExport</code> n'a aucun champ span.</p>
<p>➡️ <strong>Décision : le bento VR doit porter sur les sections</strong>, pas sur les configurations. Sans ça les boutons resteront décoratifs quoi qu'on fasse. Ça demande <code>GridColSpan</code>/<code>GridRowSpan</code> sur <code>Section</code> (entité, DTO, migration, client API édité à la main) — l'export les transporte ensuite gratuitement — puis un placement dense par spans côté Unity. <strong>L'arc est conservé</strong> : ce n'est pas un défaut de rendu, un mur plat se regarde de biais sur ses bords. La grille est calculée en cellules, puis chaque cellule est <em>mappée</em> sur un angle et une hauteur. Cible <strong>6 colonnes × 2 rangées</strong> — le confort visuel en VR c'est ±60°, ce qui donne naturellement le format paysage proche du rendu web.</p>
<p>🟠 <strong>La notification annonce la mauvaise app.</strong> Modifier un contenu VR affiche « application mobile mise à jour ». Le paramètre existe déjà (<code>appUpdatedLabel</code>, <code>app_configuration_link_screen.dart:144</code>) et n'est simplement pas passé par <code>vr_screen.dart</code>. Une clé i18n et quatre lignes — <strong>~15 minutes</strong>.</p>
<p>🟠 <strong>La popup d'édition d'un casque est celle du kiosk.</strong> Largeur figée à 580, une colonne scrollée, et deux boutons dont les libellés « Annuler »/« Changer » sont <strong>en dur</strong>. Mais le vrai problème n'est pas la mise en page : <strong>la moitié des champs est morte en VR</strong> — couleur principale, couleur de fond, loader, pourcentage d'arrondis, place des sections, fond d'image, heure, date. <code>grep -r</code> sur <code>Assets/Scripts</code> : <strong>zéro occurrence</strong> de chacun. Un casque n'a ni écran de chargement 2D ni horloge d'accueil. À remplacer par une popup VR dédiée, paysage deux colonnes.</p>
<p>⚠️ <strong>Ce que ce chantier dit du reste</strong> : les quatre écarts ont la même cause — un écran partagé par quatre canaux qui n'ont pas les mêmes réglages. Le plan recommande d'<strong>en sortir</strong> pour la VR plutôt que d'y ajouter un quatrième cas particulier.</p>
<p><strong>Ordre</strong> : la notification (indépendante, 15 min) → la popup (indépendante) → les spans backend + manager → le rendu Unity (rien de visible avant). Quatre mini-prompts de lancement sont en fin de plan.</p>

View File

@ -24,6 +24,7 @@ Plans détaillés, pas encore démarrés en dev (sauf mention contraire) :
| AI Persona par venue (guide IA personnalisable) | [v2/myinfomate-ai-persona-analysis.md](../v2/myinfomate-ai-persona-analysis.md) | Architecture de référence pour les chantiers IA ci-dessus |
| Lunettes Ray-Ban Meta | [roadmap.md](../roadmap.md) (section XR), [rayban-meta-integration.md](../rayban-meta-integration.md) | 🔨 **POC fonctionnel — bien plus avancé que ce que cette ligne disait jusqu'au 2026-08-09.** Voir §5bis |
| VR Meta Quest (borne office tourisme) | [v2/vr-quest-unity-plan.md](../v2/vr-quest-unity-plan.md) · [roadmap.md](../roadmap.md) (section XR) | 📦 **V2** — ⚠️ **« zéro ligne de code » était faux** (corrigé le 2026-08-31, relevé dans le code). Le canal VR est déjà dans le modèle : `AppType.VR`, `Instance.IsVR` en base depuis juillet 2025, sous-menu « VR » branché dans `main_screen.dart:65`, filtre stats + i18n `statsChannelVR` prêts. **Ce qui manque** : l'écran de flotte (`main_screen.dart:704` = `Text("TODO vr")`), un `Device.AppType` (le `DeviceController` est hardcodé sur `Tablet`), et l'app Unity. Back-office XR : **8-12 j**. App Unity : **3-4 mois**. La landing annonce « Meta Quest — Bientôt » en 4 langues (`translations.ts`, clé `mode4Desc`) |
| Tags NFC (déclencheur de section) | [v2/nfc-triggers-plan.md](../v2/nfc-triggers-plan.md) | 📦 **V2** — analysé dans le code le 2026-09-16. Troisième déclencheur physique à côté du QR et du beacon. ⚠️ **Le coût n'est pas le NFC** : les **Universal Links / App Links n'existent pas** (aucun `assetlinks.json` ni `apple-app-site-association`, `app_links` branché seulement sur le retour Meta AI, `main.dart:69`) — ce lot-là **sert aussi au QR**, qui aujourd'hui n'ouvre pas l'app quand il est scanné par l'appareil photo natif. Le tag porte **la même URL que le QR** : aucune entité, aucun endpoint de résolution en v1. Dépend du **déploiement de `visitapp-web`** pour le fallback sans app. 4-7 j |
| Canal subsides | [plan-import-ia-stats-subsides.md](../plan-import-ia-stats-subsides.md) | Actions administratives/réseau, pas de dev |
---

View File

@ -9,20 +9,67 @@
---
## Comment passer ce plan — ajouté le 2026-09-16
**Quatre séances, dans cet ordre.** Chacune est arrêtée par la précédente : inutile de chercher un bug
de narration si l'upload est cassé en dessous.
| Séance | Sections | Durée | Ce qu'un ❌ arrête |
|---|---|---|---|
| **1. Le socle** | § 0, § 0bis | ~45 min | Tout. Rien d'autre ne se teste |
| **2. L'argent et l'image** | § 1, § 2, § 2bis | ~1 h | Les § 3 à 5 (pas de génération fiable, pas de narration) |
| **3. La narration** | § 3, § 4 | ~1 h 30 | Les § 5 et 5bis côté visiteur |
| **4. Les trois fronts** | § 5, § 5bis, § 6 | ~1 h | Rien — c'est la fin |
**Comment reporter.** Un ✅ tout court suffit quand c'est conforme. Un ❌ demande trois choses et pas
une de plus : *ce que tu as fait*, *ce que tu as vu*, *ce que tu attendais*. Une capture vaut mieux
qu'une description pour tout ce qui est visuel. N'essaie pas de diagnostiquer — c'est mon travail, et
un diagnostic dans la colonne ✓ masque souvent le vrai symptôme.
**Ce qui n'est pas un bug** : la liste des limites connues en fin de document. À lire **avant** de
commencer, pas après avoir signalé l'une d'elles.
---
## 0. Prérequis — une fois
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 0.1 | `winget install Gyan.FFmpeg`, rouvrir le terminal, `ffmpeg -version` | Une version s'affiche. Sans ffmpeg, la génération audio échoue et les crédits sont remboursés | |
| 0.2 | `dotnet user-secrets set "AI:ApiKey" "<clé Gemini>" --project manager-service/ManagerService` | `dotnet user-secrets list` montre `AI:ApiKey` (et `Studio:Fal:Key`) | |
| 0.3 | Lancer Postgres puis `dotnet ef database update` avec `MIGRATIONS_CONNECTION` (voir la note mémoire EF) | Migrations appliquées : `AddAiWatermarkBurned`, `StudioCalibrationEngravingStyle`, `StudioCreditUnitPerReference`, `AddNarrators`, `AddGeoPointAudio`, `AddCasting` | |
| 0.1 | `winget install Gyan.FFmpeg`, rouvrir le terminal, `ffmpeg -version` | Une version s'affiche. Sans ffmpeg, la génération audio échoue et les crédits sont remboursés | ✅ 16/09 — 9.0.1-full_build, alias winget dans le Path persistant |
| 0.2 | `dotnet user-secrets set "AI:ApiKey" "<clé Gemini>" --project manager-service/ManagerService` | `dotnet user-secrets list` montre `AI:ApiKey` (et `Studio:Fal:Key`) | ✅ 16/09 — « Successfully saved AI:ApiKey » (Thomas) |
| 0.3 | Lancer Postgres puis `dotnet ef database update` avec `MIGRATIONS_CONNECTION` (voir la note mémoire EF) | Migrations appliquées : `AddAiWatermarkBurned`, `StudioCalibrationEngravingStyle`, `StudioCreditUnitPerReference`, `AddNarrators`, `AddGeoPointAudio`, `AddCasting` | ✅ 16/09 — les 6 appliquées en une passe, « Done. » |
| 0.4 | `dotnet run` (manager-service), `flutter run -d chrome` (manager-app) | Les deux démarrent, connexion SuperAdmin OK | |
| 0.5 | Dialogue d'instance (SuperAdmin) : assistant **et** Studio activés, **100 crédits** accordés | Solde 100 dans la jauge du menu | |
| 0.5 | **Dialogue d'instance** — il n'est pas dans un écran : **pied du menu de gauche → icône ⇄ « Changer d'instance »** (visible seulement en SuperAdmin) → dans la liste, le **crayon** « Configurer le plan » de la ligne voulue. Activer **Assistant IA**, puis **Studio**, puis accorder **100 crédits** | Solde 100 dans la jauge du menu | |
| 0.5b | ⚠️ Dans ce même dialogue, **décocher Assistant IA** | L'interrupteur **Studio se désactive tout seul** et devient grisé : pas de Studio sans assistant (décision 12). Recocher les deux avant de continuer | |
| 0.6 | Guide IA Personnages : un personnage **Léon** avec une **voix** (Umbriel) et une intonation, un second **La sentinelle** avec une autre voix (Kore) | Les deux apparaissent dans la liste, sans « (sans voix) » | |
| 0.7 | Donner un **portrait** à Léon (onglet Visage → générer ou choisir une vue Portrait) | La vue Portrait est enregistrée | |
---
## 0bis. Le socle d'upload — ajouté le 2026-09-16
> **Pourquoi cette section n'existait pas, et pourquoi elle doit exister.** Le lot 0 a **réécrit tout le
> chemin d'upload** de manager-app : plus aucun appel au SDK Firebase, tout passe par une URL V4 signée
> puis `POST /ingest`. C'est codé depuis le 13/09 et couvert par des tests unitaires, mais **aucun
> fichier n'a jamais été déposé depuis un navigateur**. Les § 1 à 5 supposent tous que ça marche : la
> médiathèque reçoit les images générées, les MP3 de narration, les portraits. Tester la narration
> avant ce socle, c'est chercher une fuite au toit avant d'avoir regardé les fondations.
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 0b.1 | Médiathèque : déposer une **image JPEG** de plus de 2560 px | Elle monte (barre de progression), apparaît dans la grille, s'ouvre. Réduite à 2560 px côté long | |
| 0b.2 | Déposer un **PNG à transparence** | Reste un PNG, l'alpha est préservé (le voir sur fond coloré) | |
| 0b.3 | Déposer un **MP3**, un **PDF** | Montent tels quels, lisibles / téléchargeables depuis la fiche | |
| 0b.4 | **Remplacer** le fichier d'une ressource existante | Le nouveau s'affiche partout où la ressource était utilisée, l'ancien ne traîne pas | |
| 0b.5 | **Supprimer** une ressource non utilisée | Disparaît de la grille ; recharger la page ne la ramène pas | |
| 0b.6 | ⚠️ Fichier **au-dessus du plafond de son type** (ex. une image > 30 Mo) | Refusé avec un message traduit, **pas** une erreur brute ni un échec muet. Tester en FR, EN **et** NL | |
| 0b.7 | ⚠️ **Sélecteur de ressource** : champ image d'une section → ajouter un fichier depuis la modale → Enregistrer | Le fichier arrive **et** le champ de la section le pointe. C'est le piège historique : la modale embarque `ResourcesScreen`, une régression ici touche les 13 types de section | |
| 0b.8 | Ouvrir une ressource déposée depuis **visitapp-web** et depuis **mymuseum-visitapp** | L'URL s'ouvre des deux côtés (le jeton de téléchargement est bien posé à l'écriture) | |
| 0b.9 | Remplacer l'image d'une visite **déjà téléchargée** dans mymuseum-visitapp | L'app la re-télécharge, n'affiche pas l'ancienne | |
| 0b.10 | Sélecteur de langues d'une configuration | **Le luxembourgeois (LB) est proposé**, avec son drapeau (ajouté au lot 0, jamais vu à l'écran) | |
---
## 1. Mention IA dans le fichier (lot 6)
| # | Étape | Attendu | ✓ |
@ -49,6 +96,25 @@
---
## 2bis. Les crédits, pour de vrai — ajouté le 2026-09-16
> Le § 2 vérifie que **l'estimation** affichée est juste. Cette section-ci vérifie que **le solde**
> bouge juste, ce qui n'est pas la même chose : le mécanisme est une réservation puis un débit réel,
> avec remboursement en cas d'échec, et rien de tout ça n'a tourné en conditions réelles.
| # | Étape | Attendu | ✓ |
|---|---|---|---|
| 2b.1 | Noter le solde, lancer une génération de 3 variantes, **regarder la jauge pendant** le travail | Le solde baisse **dès le lancement** (réservation), pas à l'arrivée des images | |
| 2b.2 | À la fin | Le débit définitif égale l'estimation annoncée, ni plus ni moins | |
| 2b.3 | Fermer le panneau **sans valider** aucune variante | Les crédits restent dépensés (l'appel a coûté), mais **rien** ne s'ajoute au quota de stockage | |
| 2b.4 | Lancer **deux générations coup sur coup** sans attendre la première | Les deux réservations se cumulent ; le solde ne descend jamais sous zéro | |
| 2b.5 | Solde insuffisant pour 3 variantes mais suffisant pour 1 | Refus **avant** l'appel, message clair, aucun crédit pris | |
| 2b.6 | ⚠️ Génération qui échoue (couper le réseau du serveur vers fal, ou fausse clé) | Le solde **revient** à sa valeur d'avant. C'est le point le plus important de la section | |
| 2b.7 | Abonnement journal des crédits | Une ligne par mouvement (réservation, débit, remboursement), export CSV lisible | |
| 2b.8 | SuperAdmin : accorder 50 crédits à l'instance | Solde +50, et la **date d'expiration repoussée à +12 mois sur tout le solde** (pas seulement sur les 50) | |
---
## 3. Qui raconte quoi (lot 8a et 8c)
Préparer une configuration avec un **parcours de 4 étapes** (titre + description remplis en FR et NL) et un **article** avec du texte.
@ -159,3 +225,23 @@ ajouter **La cuisinière sans portrait** comme narratrice d'une étape).
- Hors ligne, mymuseum-visitapp ne montre pas le portrait.
- Le portrait affiché est le portrait **actuel** du personnage ; le nom et la voix sont ceux de l'audio entendu.
- Les extraits d'écoute des voix (lot 7) ne sont pas encore produits.
---
## Ce que ce plan ne couvre **pas** — ajouté le 2026-09-16
À savoir avant de conclure « le Studio est testé » :
- **L'infra du bucket de prod.** Le lot 0 est codé mais son volet infra reste ouvert : CORS des origines
de prod, cycle de vie `incoming/`, et surtout **fermeture des règles Storage en écriture** — qui doit
se faire *après* le déploiement du nouveau manager-app, sinon l'upload de la version encore en ligne
casse. Rien de tout ça ne se voit depuis un poste de dev.
- **Le plafond journalier par utilisateur** : il *est* configurable, champ « Plafond par utilisateur et
par jour (crédits, 0 = aucun) » dans le dialogue d'instance, sous l'interrupteur Studio. Il vaut 0 par
défaut, donc rien ne se déclenche tant qu'on n'y met pas une valeur — à tester le jour où on veut
vérifier le garde-fou « stagiaire », pas dans cette passe.
- **Le webhook fal** en conditions réelles : en local `Studio:WebhookBaseUrl` est vide, la relecture se
fait par sondage. La vérification de signature ED25519 ne sera exercée qu'en préprod.
- **La charge** : tout ce plan se passe à un seul utilisateur. Le sémaphore à 2 images simultanées de
l'ingestion ne se voit pas à cette échelle.
- **Les lots 9 à 11** (avant/après, vidéo, 3D) : pas commencés.

54
v2/nfc-triggers-plan.md Normal file
View File

@ -0,0 +1,54 @@
# Tags NFC comme déclencheurs de section
> Chantier **V2**, **analysé dans le code le 2026-09-16** à partir d'une spec d'intention.
> Carte kanban : `cards/5-planifie/265-…`
> Ce document **corrige la spec d'origine sur deux points de réalité** (le mot « POI », et
> le schéma d'URL avec table de mapping), puis dit comment on le fait.
---
## Ce que la spec d'origine supposait, et ce que dit le code
| Hypothèse de la spec | Réalité relevée |
|---|---|
| « Déclencher un **POI** » | Dans MyInfoMate, un **POI est un point posé sur un GLB** (3D/VR — voir `immersif-frontiere-plan.md` §9). Le déclencheur physique d'un contenu, c'est une **`Section`** : `Section.IsBeacon` / `Section.BeaconId` (`Section.cs:57`), lue côté visiteur par `geo_beacon_trigger_service.dart:177` et `configuration_page.dart:205`. Le NFC se branche **au même endroit que le beacon**, pas ailleurs |
| « Réutiliser l'endpoint de résolution des QR, s'ils utilisent déjà une URL de ce format » | **Il n'y en a aucun.** Les QR portent l'id de section **directement dans l'URL**, et trois formats coexistent sur le terrain (`ScannerDialog.dart:31-38`) : `app.myinfomate.be/download/{instanceId}/{configId}/{sectionId}`, `web.mymuseum.be/{instanceId}/{configId}/{sectionId}` (déjà imprimé, MDLF), et un **id de section brut** (déjà imprimé, Fort Saint-Héribert). Il n'y a donc rien à réutiliser : l'entité « tag », le code opaque et l'endpoint `code → section` seraient **du neuf intégral** |
| « Déclarer les URLs en Universal Links / App Links » | **Rien n'existe.** Aucun `assetlinks.json` ni `apple-app-site-association` sur nos domaines (le seul du workspace est dans `OpenGlasses`, qui n'est pas à nous). `app_links` est bien au `pubspec.yaml:89` mais branché **uniquement sur le retour de deep link Meta AI** (`main.dart:69``MetaGlassesService.handleDeepLink`), et `AndroidManifest.xml` n'a pas d'intent-filter `autoVerify` sur un domaine http |
| « Si l'app n'est pas installée, l'URL affiche une page web utile » | `visitapp-web` fait exactement ça (`app.myinfomate.be/[slug]`), mais **il n'est pas déployé** — il manque le Dockerfile et l'entrée dans le compose (carte « Déployer le visiteur web »). Le fallback est une **dépendance**, pas un acquis |
---
## Décisions
| Décision | Conséquence |
|---|---|
| **1. Le tag porte la même URL que le QR** | Pas d'entité `NfcTag`, pas de code opaque, pas d'endpoint de résolution, pas de CRUD manager. Le mapping tag → section, c'est l'URL écrite sur le tag. **Zéro ligne de backend pour la v1** |
| **2. L'indirection `code → section` est reportée, pas refusée** | L'argument « réassigner sans réécrire le tag » est réel, mais il vaut **beaucoup moins en NFC qu'en QR** : réécrire un NTAG prend deux secondes avec un téléphone, alors qu'un QR se réimprime, se replastifie et se recolle. On ouvrira l'indirection le jour où un client gère un parc de tags — et ce jour-là elle devra couvrir **QR et NFC ensemble**, pas le NFC seul |
| **3. Le lot utile est celui des liens, pas celui du NFC** | Universal Links + App Links + routage du deep link entrant vers la section. ✅ **Le QR en bénéficie autant** : aujourd'hui un QR scanné par l'appareil photo natif n'ouvre pas l'app, il ouvre le navigateur. C'est ce lot qui porte la valeur, le NFC n'en est qu'un second émetteur |
| **4. Lecture in-app en fallback seulement** | `nfc_manager` derrière le bouton Scanner existant (`ScannerBouton.dart`), à côté du QR. Le cas nominal reste **le tag lu sans ouvrir l'app** — c'est tout l'intérêt du canal |
| **5. Écran de programmation des tags : Android-only** | L'écriture NDEF depuis iOS est trop contrainte pour un outil de terrain, et le public visé est **l'admin qui pose les tags**, pas le visiteur. Un seul appareil de programmation suffit à un lieu |
| **6. Verrouillage en écriture : hors v1** | Irréversible, donc une erreur de pose coûte le tag. À ouvrir après un vrai déploiement, avec double confirmation |
| **7. Pas de lecture par UID** | Confirmé : l'UID n'est pas réassignable et impose une table de correspondance — donc tous les inconvénients de la décision 2 sans son bénéfice |
---
## Ce qui ne marchera pas — à savoir avant de le vendre
- **Aucune lecture NFC sur `visitapp-web`.** WebNFC est Chrome/Android uniquement, jamais Safari. Le plan **Essentiel étant web-only**, le NFC ne s'y vend pas comme « scan dans l'app » — il s'y vend comme « on approche le téléphone, une page s'ouvre », ce qui est vrai et ne demande rien de plus que la décision 1.
- **iPhone 7 / 8 / X** ne lisent pas les tags en arrière-plan : il leur faut l'app ouverte. iPhone **XS et plus** lisent un NDEF sans aucune app installée — c'est l'argument commercial réel, et il est bon.
- **Un tag à plat sur du métal ne se lit pas.** Prévoir des tags sur mousse ou des NTAG « on-metal » ; le raccourci du bois transparent au BLE (carte beacons) n'a pas d'équivalent ici.
- **Le NFC ne remplace pas le beacon** : il demande un geste volontaire à 1-2 cm, il ne déclenche rien à l'approche. Les trois déclencheurs se cumulent, ils ne se substituent pas.
---
## Découpage
| Lot | Contenu | Estimation |
|---|---|---|
| **A — Liens universels** (le seul indispensable) | `assetlinks.json` + `apple-app-site-association` servis sur le domaine, intent-filter `autoVerify` + capability Associated Domains, routage du deep link entrant vers la section (logique partagée avec le parsing de `ScannerDialog`, à extraire du dialogue). **Bénéficie au QR dès la livraison** | 2-3 j |
| **B — NFC** | Permission et intent-filter NDEF Android, capability NFC Tag Reading + `NFCReaderUsageDescription` + entitlement iOS, `nfc_manager` derrière le bouton Scanner | 1-2 j |
| **C — Écran de programmation admin** | Choisir une section → approcher un tag → écrire l'URL NDEF. Android-only, NTAG213/215/216 | 1-2 j |
| **D — Fallback web** | Rien à écrire : c'est le déploiement de `visitapp-web`. **Dépendance externe** | — |
| *(reporté)* **E — Indirection `code → section`** | Entité, CRUD manager, endpoint de résolution — et alors **pour QR et NFC ensemble** | 3-4 j |
**Total v1 (A+B+C) : 4-7 jours**, dont A porte à lui seul la moitié de la valeur et sert déjà au QR.

View File

@ -161,6 +161,8 @@ Tout sur `Instance`, dupliqué depuis `SubscriptionPlan` : `StorageQuotaBytes`,
| 22 | Plafond dur et dotation de plan — *13/09, lot 1* | **Retirés.** Avec des crédits prépayés (décision 17), le solde *est* le plafond : un « plafond dur organisation » n'a plus rien à borner. Seul reste `StudioPerUserDailyCap`, en crédits. `SubscriptionPlan.HasStudio` / `StudioCreditsGranted` et `Instance.StudioProviderRegion` ne sont pas créés tant que rien ne les lit (prix différés, décision 16 ; région déclarative) |
| 23 | Écarts des lots 2 à 4 — *13/09* | **Fournisseur interchangeable** : `IGenerationProvider` (soumission, sondage, lecture du webhook) + `GenerationModel.ProviderKey` en base ; changer de fournisseur = une classe et une ligne de catalogue. **Une requête par variante** (FLUX.2 pro n'a pas de `num_images`). **Référence source avant celles de l'identité** dans l'ordre d'envoi. Migration unique `AddStudioGeneration` pour les lots 2 et 3. **Non livrés** : `preview`, `GET /jobs`, `cancel`, `reject`, `regenerate`, l'interrupteur UI de `CanValidateAssets`. **Pas de `target` serveur** : l'onglet « Générer » vit dans le sélecteur (`showSelectResourceModal`, mode sélection) et la ressource validée revient au champ qui l'a ouvert — donc absent du bouton « Ajouter » de la Médiathèque, qui relève du lot 5. Surcharges d'identité en puces, pas en onglets ; `ModelKey` non exposé (un seul modèle). **`GuidedStep.ImageUrl` conservée** et remplie par le client avec `ImageResourceId` (motif `GeoPoint`) : visitapp-web n'a rien à changer, mymuseum-visitapp lit l'id pour l'hors-ligne, tablet-app n'a pas d'étapes. `CreditCost = 10` provisoire jusqu'au calibrage || 24 | Modèles 3D et multi-vues — *15/09* | **Tout reste sur fal** : Meshy (v5, v6-preview, v7), Hi3D, Tripo3D, Hunyuan3D et Trellis y sont exposés — aucun second fournisseur à intégrer, aucune seconde clé. **Deux lignes de catalogue, pas une** : `3d-default` mono-image (~0,020,05 $) et `3d-multiview` = `meshy/v7/multi-image-to-3d` (~1,20 $). Le facteur ~25 sur le coût impose que le basculement de l'un à l'autre soit **annoncé avant la génération, jamais silencieux**. Hi3D n'expose pas de multiview : excellent en fidélité structurelle, il ne couvre pas le cas d'usage central. Ids et prix à revérifier au calibrage (§9.4) |
| 25 | Casting visiteur — *15/09* | **Retenu** — lève le « hors périmètre » de §3.8. `ConfigurationPersona { ConfigurationId, PersonaId, Order, IsFeatured }` ne porte **que** l'ordre et la mise en avant : l'appartenance reste **calculée** depuis les narrateurs assignés. Un personnage ne s'assigne qu'à **un seul endroit — le contenu** ; l'écran casting est une *vue*, jamais une seconde saisie. Rien ne s'affiche côté visiteur sans `Configuration.IsCastingShownToVisitor` (défaut off). Lot 8bis |
| 26 | Prise de vue mobile — *16/09* | **Page de dépôt dédiée ouverte par QR code, pas un manager-app responsive.** Le conservateur est devant la vitrine avec son téléphone, pas devant son poste ; rendre responsive un back-office Flutter web pour un seul écran revient à le rendre responsive tout court — refus déjà posé sur le routing (décision 23) — et CanvasKit se charge mal sur la 4G d'une salle. La page vit sur **la même surface web non-Flutter que l'éditeur de POI** (décision n°9 d'[immersif-frontiere-plan.md](immersif-frontiere-plan.md)) : aucune nouvelle cible de déploiement. Capture par `<input type="file" accept="image/*" capture="environment">`, qui ouvre l'appareil photo natif — **jamais `getUserMedia`**, dont le flux est sans autofocus ni traitement constructeur, alors que c'est le seul gabarit du Studio où la qualité des photos pèse plus lourd que le prompt. Accès par **token de dépôt** borné (§3.7bis), jamais un JWT manager. **Lot 11bis, après le lot 11** : le dépôt de fichiers depuis le poste couvre déjà le besoin fonctionnel |
| 27 | Alerte de solde, pas de nouveau plafond — *16/09* | **Le plafond reste retiré** (décision 22) : avec des crédits prépayés, le solde *est* le plafond, et `StudioPerUserDailyCap` couvre déjà le budget vidé en une après-midi. Ce qui manque est le **préavis** — le modèle n'est pas celui de Google AI Studio ni de fal.ai, **postpayés**, où l'alerte protège d'une facture ; ici elle protège d'une **indisponibilité**. On ajoute `Instance.StudioCreditsAlertThreshold` (0 = désactivé) + `StudioCreditsAlertSentAt` (anti-répétition), déclenchés dans `StudioCreditService.Record` au franchissement du seuil et dans `ExpireDueAsync` pour un rappel **d'expiration à J-30** — c'est le vrai risque de la décision 17, un solde qui s'évapore en silence chez une institution publique. Envoi par `IEmailService` (Resend, déjà en place), champ dans l'écran Facturation et bandeau dans le popover de la jauge. **Après la décision 16** : un seuil en crédits n'a pas de valeur par défaut tant que l'unité n'est pas fixée |
---
@ -731,6 +733,39 @@ publiques exposées en JWKS :
répond `200` sans rien refaire. D'où le téléchargement des octets dans un job, pas dans la requête ;
- corps : `{ request_id, gateway_request_id, status: "OK" | "ERROR", payload | error }`.
### 3.7bis Token de dépôt — le seul chemin d'écriture non authentifié
Réservé au lot 11bis (décision 26). À lire avant de l'écrire : ce serait **le seul endroit de la
solution où un client sans session peut faire écrire dans le bucket**, et les règles Storage de prod
sont ouvertes en écriture depuis 2024. Un token laxiste transformerait le QR en hébergeur de fichiers
gratuit pour qui le photographie par-dessus l'épaule.
```
StudioSourceToken
Token -- aléatoire 32 octets, indexé, jamais dérivé d'un id
JobDraftId -- le brouillon de génération 3D, et lui seul
InstanceId
SlotsLeft -- 4 au départ, décrémenté à chaque URL délivrée
ExpiresAt -- maintenant + 30 min : la durée d'une prise de vue
CreatedBy -- l'utilisateur qui a affiché le QR, pour le journal
```
Quatre bornes, toutes côté serveur :
- **portée d'un seul brouillon** — il n'autorise que le dépôt dans
`studio-sources/{instanceId}/{draftId}/{slot}`, aucune lecture, aucun autre chemin ;
- **durée courte** — 30 min, cohérent avec les 15 min des URLs signées de la décision 14 ;
- **compteur borné** — 4 URLs délivrées, puis le token est mort, même avant expiration ;
- **il ne signe rien lui-même** — il ouvre `POST /api/Studio/source-upload-url`, qui applique le
plafond de taille (`x-goog-content-length-range`) et le quota exactement comme le flux authentifié.
Le token est une autorisation, pas une capacité.
**Les photos sources ne sont pas des `Resource`.** Ce sont des entrées de génération : elles vivent
sous `studio-sources/`, hors quota de médiathèque, et tombent avec une règle de cycle de vie comme
celle posée sur `incoming/` au lot 0. Elles ne passent donc **pas** par `ResourceIngestionService`
ce qui veut dire que **le lot 0, déjà codé le 13/09, n'est pas à rouvrir**. Seul le GLB produit
devient une `Resource`.
### 3.8 Personnages — fusion des trois plans, arrêtée le 2026-09-01
> **Trois plans décrivaient le même objet sans se croiser.** Le canon visuel (ce plan), les 3 frames
@ -1526,6 +1561,10 @@ l'écran doit être construit autour de ça.
Chaque case porte une silhouette d'exemple. Sous le bloc, **une seule consigne**, courte :
*même objet, même lumière, fond neutre, objet entier dans le cadre.* C'est la recommandation du
modèle, et elle pèse plus lourd que n'importe quel réglage.
Chaque case accepte un fichier **ou** une photo prise sur place (lot 11bis). ⚠️ **La grille de
quatre est aussi un plafond, pas une mise en forme** : `meshy/v7/multi-image-to-3d` prend **2 à 4
vues**, une cinquième fait rejeter l'appel.
3. **Le coût se met à jour en direct, et le changement de modèle est annoncé** : 1 photo → modèle
mono-image ; dès la 2ᵉ → Meshy v7 multi, **~25× plus cher**. Une ligne explicite au moment où
la bascule se produit, jamais une facture découverte après coup (même principe que le coût de
@ -1533,6 +1572,22 @@ l'écran doit être construit autour de ça.
4. À l'inverse, **ne pas culpabiliser le mono-image** : une seule photo reste un usage légitime et
bon marché pour une illustration. La formulation est « plus de vues = plus fidèle », pas
« vous avez mal fait ».
4bis. **Les N photos sources ne touchent pas `GenerationJob.SourceResourceId`.** Vérifié dans le code le
16/09 : cette colonne est **singulière** et porte un id de `Resource`
(`AddStudioGeneration:47`, `StudioGenerationService.cs:238`) — l'élargir en liste rouvrirait les lots 3
et 4, leurs tests et le chemin image qui fonctionne. À la place, une **table fille** dans la migration
du lot 11 :
```
StudioSourceFile
JobDraftId -- le brouillon de génération
Slot -- Face | Profil | Dos | TroisQuarts
StoragePath -- studio-sources/{instanceId}/{draftId}/{slot}
```
`SourceResourceId` reste intact pour le chemin image. C'est le pendant en entrée du multi-fichier en
sortie, et ça suit la décision 21, qui réserve déjà le job composite à la 3D.
5. Génération asynchrone en **job composite** (parent + enfants, décision 21), puis **normalisation
canonique du GLB** à l'ingestion — échelle, pivot, orientation (décision n°2 d'
`immersif-frontiere-plan` §5) : sans elle les POI posés sur un modèle sautent à la
@ -1543,6 +1598,33 @@ l'écran doit être construit autour de ça.
7. La ressource validée revient au champ qui a ouvert le sélecteur, et les **POI se posent dans
l'éditeur three.js en iframe** (décision n°9) — le vrai morceau non trivial du chantier.
### Lot 11bis — Prise de vue mobile par QR *(petit, juste après le lot 11)*
Décision 26. Le lot 11 suppose que le conservateur a déjà ses quatre JPEG sur son disque ; devant une
vitrine, il a un téléphone. Ce lot ferme l'écart **sans toucher au responsive de manager-app**.
1. Bouton « **Photographier avec mon téléphone** » à côté de la grille des quatre emplacements. Il
crée un `StudioSourceToken` (§3.7bis) et affiche le QR de l'URL de dépôt.
2. **Page de dépôt** — même surface web non-Flutter que l'éditeur de POI, route
`/studio/capture/{token}`. Quatre cases (Face / Profil / Dos / 3/4), la même consigne unique que
sur le poste, un `<input capture="environment">` par case. Pas de session, pas de routing, pas
d'état : demander l'URL signée, `PUT`, afficher la vignette.
3. **Le poste sert d'écran de contrôle** : les vignettes se remplissent en direct pendant la prise de
vue, par le **polling 2 s déjà retenu au §3.7** — aucun SSE à introduire pour ça.
4. Token expiré ou épuisé → la page le dit et renvoie vers le poste pour régénérer le QR. Le bouton
« Générer » reste sur le poste : **le téléphone dépose, il ne dépense pas de crédits.**
⚠️ **Le réseau est le point faible, pas la photo.** Une salle d'exposition en sous-sol coupe la 4G,
et une page web sans service worker perd les clichés déjà pris. Acceptable ici — l'utilisateur est un
conservateur qui recommence, pas un visiteur — mais c'est *cet* argument qui ferait revenir un jour
l'idée d'une application native, jamais la qualité d'image.
**Ne pas inverser l'ordre avec le lot 11.** « Je photographie, je m'envoie les photos, j'uploade
depuis le poste » coûte zéro et marche dès le premier jour. La valeur du chantier est dans la qualité
des quatre vues, l'annonce du basculement mono→multi à ×25 et la normalisation canonique du GLB.
---
⚠️ **Ce lot faisait quatre lignes et se contentait de renvoyer au plan VR, qui lui-même renvoyait
ici.** La frontière est tranchée depuis le 2026-09-11 dans
[immersif-frontiere-plan.md](immersif-frontiere-plan.md), qui ajoute : `Scene3D` comme
@ -1592,6 +1674,9 @@ la première migration du Studio, pas en arrivant au lot 11.
| « Tout par le serveur » (première version de la décision 14) | `MemoryBufferThreshold = int.MaxValue` (`Startup.cs:112`) tient tout `IFormFile` en RAM, et une vidéo 360 de 1 Go traverserait le VPS | Abandonné le jour même : les octets vont directement chez Google |
| Objet écrit par le SDK GCS | Pas d'URL de téléchargement Firebase sans la métadonnée `firebaseStorageDownloadTokens` | Jeton posé à l'écriture, URL construite (§5) |
| Trois décisions d'`immersif-frontiere-plan` §5 « à écrire avant la première migration » | Seule la n°5 (crédits) avait été reportée | N°1, 4 et 6 reportées le 13/09 : décisions 14, 20, 21 |
| « Rendre manager-app responsive pour photographier l'objet » | Back-office Flutter web : le responsive d'un seul écran entraîne celui du routing, déjà exclu du périmètre (décision 23) — et CanvasKit à charger sur la 4G d'une salle pour faire un `<input type="file">` | Page de dépôt dédiée ouverte par QR, sur la surface web de l'éditeur de POI (décision 26, lot 11bis) |
| « Capture par `getUserMedia` » | Flux vidéo sans autofocus ni traitement constructeur, et manager-app n'a aucun binding caméra JS-interop | `<input type="file" accept="image/*" capture="environment">` : appareil photo natif, JPEG pleine résolution, retombe sur le sélecteur de fichiers sur poste |
| « Les photos sources passent par l'ingestion du lot 0 » | Le lot 0 est **codé depuis le 13/09**, et une photo source n'est pas une `Resource` : c'est une entrée de génération | `studio-sources/`, hors quota médiathèque, balayé par le cycle de vie. Lot 0 non rouvert |
---
@ -1662,6 +1747,13 @@ la première migration du Studio, pas en arrivant au lot 11.
`x-goog-content-length-range` — sans quoi le navigateur refuse l'envoi.
- **Cycle de vie** : règle `age: 1` sur le préfixe `incoming/` — un envoi jamais ingéré disparaît seul,
sans job Hangfire. S'ajoute à la règle des versions non courantes posée le 07/09.
- **Cycle de vie à prévoir dans le même geste** : règle équivalente (`age: 7`) sur `studio-sources/`, le
préfixe des photos sources de la 3D (lot 11bis, §3.7bis). Rien ne les balaie autrement : ce ne sont
pas des `Resource`. À poser maintenant coûte une ligne, plus tard coûte de le redécouvrir.
- **Origine de la page de capture dans le CORS** — case à cocher, pas encore une valeur : la config CORS
est au niveau du bucket et ne liste que les origines du manager. Tant que l'hébergement de la surface
web non-Flutter (éditeur POI + page de capture) n'est pas tranché, on ne sait pas si c'est une origine
de plus ou la même. À revérifier au lot 11bis.
- **Règles Storage : écriture fermée à tous les clients**, lecture inchangée. Relever les règles
actuelles avant. ⚠️ À appliquer **après** la mise en prod du nouveau manager-app : fermer avant
casse l'upload de la version encore déployée.

233
v2/vr-menu-bento-plan.md Normal file
View File

@ -0,0 +1,233 @@
# Menu VR — bento, et le reste de l'onglet VR
> Chantier **V2**, analysé dans le code le **2026-09-16**.
> Suite de `vr-quest-unity-plan.md` (lot XR-4, item E5 — le menu flottant existe et tourne).
> Ce document ne rouvre pas les décisions du plan VR : il traite **quatre écarts** entre ce
> que le manager laisse configurer et ce que le casque affiche réellement.
---
## Le constat
L'onglet **Main / VR** du manager ([`vr_screen.dart`](../../manager-app/lib/Screens/Vr_devices/vr_screen.dart))
réutilise `AppConfigurationLinkScreen`, l'écran de configuration partagé avec le mobile, le web
et le kiosk. Il hérite donc de tout son outillage — dont un **sélecteur de forme bento**
(les quatre petits boutons carré / large / haut / grand) qui, côté casque, ne pilote rien.
Vérifié :
| Fait | Preuve |
|---|---|
| Les spans sont portés par le **lien de configuration**, pas par les sections | `AppConfigurationLink.cs:35` — commentaire d'origine : *« Specific Mobile & Web »* |
| Ils ne pilotent que l'écran « choisis ta configuration » | `mymuseum-visitapp/lib/Screens/Home/home_3.0.dart:1179`, `visitapp-web/src/components/ConfigurationGrid.tsx:38` |
| Le casque ne les lit jamais | `ConfigurationExport.cs` n'a aucun champ span ; `grep -r "Span" Assets/Scripts` → rien |
| Un casque est appairé à **une seule** configuration | `MenuBootstrap.cs:80``pairing.ConfigurationId`. Il n'y a pas d'écran de choix de configuration en VR |
| Le menu VR affiche les **sections racines**, toutes à la même taille | `FloatingMenu.cs:106-125` — arc de 5 panneaux par rangée, `0,50 × 0,34 m` fixes |
Autrement dit : en VR, le bento du manager porte sur un objet (la configuration) qui n'a pas
d'écran de sélection, alors que l'objet qui *a* un écran (la section) n'a pas de spans.
Deux autres écarts, plus petits, de la même famille « écran partagé, canal différent » :
la notification et la popup d'édition d'un casque.
---
## Décisions actées
| Décision | Raison |
|---|---|
| **Le bento VR porte sur les sections**, pas sur les configurations | C'est le seul écran de choix qui existe dans le casque. Sans ça, les boutons resteront décoratifs quoi qu'on fasse |
| **L'arc est conservé** | Ce n'est pas un défaut de rendu : un mur plat oblige à regarder ses bords de biais. La grille bento est calculée en cellules, puis chaque cellule est *mappée* sur un angle + une hauteur |
| **Grille cible : 6 colonnes × 2 rangées max** | Le confort visuel en VR c'est ±60° horizontaux ; au-delà on encercle le visiteur. Format naturellement paysage, proche du rendu web |
| **Une popup casque distincte de celle du kiosk** | La moitié des champs de la popup partagée est morte en VR (voir lot 4) — les masquer un par un dans un widget commun coûte plus cher que d'en écrire un |
| Les spans de section **ne sont pas exposés au mobile/web en V1** | Ils ont déjà leur bento, au niveau configuration. Ouvrir un second niveau de grille sur ces fronts est un chantier produit à part |
---
## Lot 1 — La notification annonce la mauvaise app
**~15 minutes, indépendant des autres.**
Modifier un contenu VR affiche « application mobile mise à jour ». Le paramètre prévu pour ça
existe déjà et n'est simplement pas renseigné :
```
app_configuration_link_screen.dart:144
final appUpdatedMsg = widget.appUpdatedLabel ?? AppLocalizations.of(context)!.appUpdatedSuccess;
↑ jamais passé par vr_screen.dart
```
À faire :
1. Clé `vrAppUpdatedSuccess` dans `app_fr.arb` (template), puis `app_en.arb` et `app_nl.arb`.
2. `vr_screen.dart:38` — passer `appUpdatedLabel: l.vrAppUpdatedSuccess` à `AppConfigurationLinkScreen`.
3. `vr_screen.dart:78` (`_backgroundCard`) — même remplacement, c'est un `showNotification` séparé.
4. `vr_devices_tab.dart:280` (`_edit`) — idem.
⚠️ Le template i18n est `app_fr.arb` ; les trois langues sont obligatoires (cf. `manager-app/CLAUDE.md`).
---
## Lot 2 — Des spans par section (backend + manager)
**Le socle du lot 3. Rien de visible dans le casque avant le lot 3.**
### Backend
```
Data/Section.cs + int? GridColSpan, int? GridRowSpan
DTOs/SectionDTO.cs + gridColSpan, gridRowSpan (après `order`, même famille)
→ mapping dans ToDTO / FromDTO des champs communs
Migrations/ AddSectionGridSpans
```
L'export est **gratuit** côté transport : `/api/configuration/{id}/export`
(`ConfigurationController.cs:437`) sérialise déjà les `SectionDTO` complets via
`SectionFactory.ToDTO`. Les nouveaux champs suivent.
⚠️ `Section.ToDTO()` n'est pas virtuelle — vérifier que la copie des champs communs se fait
bien au même endroit que `order`, sinon les spans sortiront nuls sur les sous-types.
⚠️ `dotnet ef` ignore `appsettings.Development.json` : passer par `MIGRATIONS_CONNECTION`.
### Client API
`manager-app/manager_api_new/`**éditer les fichiers générés à la main**, ne jamais relancer
la génération OpenAPI (règle projet).
### Manager
L'onglet « Contenu de l'application VR » ne doit plus lister des *configurations* avec des
spans, mais les *sections racines* de la configuration, avec leurs spans et un aperçu.
Deux options, à trancher en début de conversation :
- **a)** Un écran VR dédié, qui reprend le sélecteur de forme et l'aperçu live de
`AppConfigurationLinkScreen` mais s'alimente en sections. Plus propre, plus de code.
- **b)** Paramétrer `AppConfigurationLinkScreen` pour qu'il accepte une source « sections ».
Moins de code, mais l'écran sert déjà quatre canaux — il est le point de contact de tous
ces écarts, l'élargir encore le rend plus difficile à corriger.
Recommandation : **(a)**, c'est précisément l'accumulation de cas particuliers dans cet écran
partagé qui produit les trois autres lots de ce document.
À réutiliser tel quel : le debounce de 400 ms sur l'enregistrement des spans
(`app_configuration_link_screen.dart:89-98`) — un slider de forme qui écrit à chaque frame
sature l'API.
---
## Lot 3 — Le rendu bento dans le casque (Unity)
### Ce qui change
`FloatingMenu.Place(panel, index, total)` positionne aujourd'hui par index brut :
`row = index / 5`, angle = pas fixe. Il faut passer par une grille.
```
1. Placement dense par spans → chaque section obtient (col, row, colSpan, rowSpan)
2. Mapping cellule → arc → angle = (col + colSpan/2 - cols/2) * StepDegrees
hauteur = HeightMeters - row * (cellH + gap)
3. Taille du quad → width = colSpan * cellW + (colSpan-1) * gap
height = rowSpan * cellH + (rowSpan-1) * gap
```
L'algorithme de placement dense existe en Dart dans `manager-app/myinfomate_layout/`
(le même que le mobile). Deux voies :
- **Le porter en C#** (~150 lignes) — le casque reste autonome, cohérent avec le cache-d'abord.
- **Le faire calculer par le backend** dans l'export — une seule implémentation, mais le
casque devient dépendant d'un champ calculé, et le cache d'un vieux contenu porte un vieux
placement.
Recommandation : **le porter en C#**, l'export reste une description, pas une mise en page.
### Fichiers
```
Assets/Scripts/Net/ConfigurationExport.cs + GridColSpan / GridRowSpan sur SectionSummary
Assets/Scripts/Menu/FloatingMenu.cs Place() → grille ; MaxPerRow 5 → 6
Assets/Scripts/Menu/MenuItemPanel.cs Create() prend une taille au lieu des const
Assets/Scripts/Menu/BentoLayout.cs (neuf) le placement dense
```
⚠️ `MenuItemPanel.WidthMeters` / `HeightMeters` sont des `const` **publiques**, lues ailleurs
(`FloatingMenu.Place`, et à vérifier dans `PagedView` / `SectionPages`). Les rendre variables
d'instance sans casser ces usages — garder les const comme valeurs de cellule par défaut est
le chemin le plus court.
⚠️ La cascade d'apparition (`PlayAppear(i * 0.04f)`) doit suivre l'ordre de **lecture** de la
grille (rangée par rangée, gauche à droite), pas l'index de la liste : avec des spans, les
deux divergent.
⚠️ Un panneau `colSpan = 2` couvre ~32° d'arc. Un quad plat reste acceptable à cette ouverture ;
au-delà (un hypothétique span 3) il faudrait le subdiviser. Ne pas le faire avant d'en avoir besoin.
### Vérification
Sans casque, le viewer web (`vr-app/viewer/`) est le chemin court pour valider un placement.
Avec casque : `adb push` d'un `scene.json`, l'app le préfère à celui embarqué.
---
## Lot 4 — La popup d'édition d'un casque
**Indépendant. ~une demi-journée.**
`showChangeInfo` ([`Kiosk_devices/change_device_info_modal.dart:16`](../../manager-app/lib/Screens/Kiosk_devices/change_device_info_modal.dart))
est partagée avec le kiosk : largeur figée à 580, une colonne scrollée, et deux boutons
`RoundedButton` dont les libellés **« Annuler » / « Changer » sont en dur** (violation i18n).
Le vrai problème n'est pas la mise en page : **la moitié des champs ne sert à rien en VR.**
Aucun script Unity ne lit ces réglages — `grep -r` sur `Assets/Scripts` : zéro occurrence.
| Champ | En VR |
|---|---|
| `name`, `configurationId` | **gardés** |
| `primaryColor`, `secondaryColor` | morts — le menu a sa propre palette de panneaux |
| `loaderImageId` | mort — un casque n'a pas d'écran de chargement 2D |
| `screenPercentageSectionsMainPage`, `roundedValue` | morts — ce sont des réglages d'écran plat |
| `isSectionImageBackground`, `isHour`, `isDate` | morts |
À faire : `Vr_devices/change_headset_modal.dart`, paysage deux colonnes (~940 px), avec
identité + configuration à gauche, fond immersif à droite (il vit aujourd'hui dans le panneau
latéral de l'onglet contenu — à décider : déplacé ou dupliqué). Boutons repris du langage
visuel de la Médiathèque / du Studio IA plutôt que des `RoundedButton` historiques.
Tout le texte passe par `AppLocalizations`, y compris les deux boutons.
---
## Ordre
```
Lot 1 ────────────────────────────── indépendant, à faire en premier (15 min)
Lot 4 ────────────────────────────── indépendant
Lot 2 ───→ Lot 3 le 3 n'affiche rien sans le 2
```
---
## Mini-prompts de lancement
Un lot = une conversation. Chaque prompt suppose que ce fichier est lisible.
**Lot 1**
> Lis `DOCS/v2/vr-menu-bento-plan.md` § Lot 1 et applique-le : la notification de l'onglet VR
> du manager annonce « application mobile ». Ajoute la clé i18n dans les trois langues et passe
> `appUpdatedLabel` aux quatre endroits listés.
**Lot 2**
> Lis `DOCS/v2/vr-menu-bento-plan.md` § Lot 2. Ajoute `gridColSpan` / `gridRowSpan` sur
> `Section` côté `manager-service` (entité, DTO, migration, client API édité à la main), puis
> fais que l'onglet « Contenu de l'application VR » du manager édite les spans **des sections**
> et non des configurations. Commence par me dire si tu pars sur l'option (a) ou (b) du plan.
**Lot 3**
> Lis `DOCS/v2/vr-menu-bento-plan.md` § Lot 3. Dans `vr-app/unity`, fais que le menu flottant
> place ses panneaux sur une grille bento par spans mappée sur l'arc, au lieu de l'index brut
> actuel. Le lot 2 doit être fait — vérifie que l'export porte bien les spans avant de commencer.
**Lot 4**
> Lis `DOCS/v2/vr-menu-bento-plan.md` § Lot 4. Écris une popup d'édition de casque dédiée en
> paysage deux colonnes, sans les champs morts en VR, avec les boutons du langage visuel de la
> Médiathèque, et tout le texte en i18n FR/EN/NL.