Commit Graph
100 Commits
Author SHA1 Message Date
williamandClaude Sonnet 5 3c39f1a249 Phase 1 (2/3) : route publique /jouer/<slug> + identité visiteur
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/play est réservé au
créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) —
un visiteur anonyme peut maintenant jouer un jeu explicitement publié en
ligne, avec sa propre partie (variables/objets per_player posés dans le
commit précédent).

core/player_identity.py : identité visiteur via un cookie NON SIGNÉ
(forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de
partition, jamais un jeton d'autorisation.

routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu
créateur mais threadées avec le vrai player_id du cookie au lieu du
sentinel PLAYER_SHARED :
- GET /jouer/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/run-variable
Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu
n'est exposé publiquement que si le créateur l'a explicitement basculé
"Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la
table générique _meta, comme game_meta.py pour 'name').

core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS
— la garde générique de connexion les laisse passer sans session, mais
chaque vue vérifie quand même is_public_played elle-même (défense en
profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py)
n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask,
qui existe pour n'importe quel visiteur (connecté ou non).

templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit
plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload',
...)) mais reçoit des URLs déjà résolues par la route elle-même
(runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url)
— nécessaire puisque ce même template sert maintenant DEUX familles de
routes (aperçu créateur ET partie publique), chacune avec ses propres
noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ
comportement) et game_play_public.py passent chacun ses propres URLs.

templates/base.html : bascule "🌐 Publier en ligne" dans la barre de
navigation du jeu, à côté de "📦 Publier" (export .zip) — deux
fonctionnalités distinctes. db/games/game_meta.py expose maintenant
is_public_played, disponible partout où `game` est dans le contexte.

Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un
bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux
cookies forge_player_id différents — qui obtiennent des valeurs de
variable indépendantes, et un qui verrouille que l'aperçu créateur reste
inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu
(aperçu créateur et partie publique).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:46:00 +02:00
williamandClaude Sonnet 5 236d6b4b46 Phase 1 (1/3) : état par joueur — couche db/ et chaîne de rendu
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fil directeur du plan : chaque variable globale et chaque objet de
données gagne un player_id (sentinelle PLAYER_SHARED='__shared__' par
défaut partout — l'aperçu créateur et tous les tests existants
continuent de fonctionner À L'IDENTIQUE, aucune signature appelée sans
argument explicite ne change de comportement), plus un réglage
per_player choisi une fois à la création :
- per_player=1 (par défaut) : chaque joueur a sa propre valeur/ses
  propres lignes.
- per_player=0 : valeur/lignes partagées par tous les joueurs (ex. un
  compteur de visiteurs global, un catalogue commun).

db/global_vars/ : _global_variables passe de UNIQUE(name) à
UNIQUE(name, player_id) — SQLite ne permet pas de modifier une
contrainte UNIQUE via ALTER TABLE, reconstruction de la table détectée
et faite une seule fois (ensure_global_vars_schema.py) pour les jeux
créés avant cette phase. La ligne "modèle" (player_id=PLAYER_SHARED,
créée par le créateur) porte le réglage per_player et sert de valeur
PAR DÉFAUT : la première écriture d'un joueur sur une variable
per_player crée paresseusement SA propre ligne (copiée depuis le
modèle) ; une lecture sans ligne encore écrite retombe sur le modèle
(nouveau resolve_player_key.py). list_global_variables() (tableau de
bord) ne montre toujours que les lignes modèles ; nouveau
list_global_variables_for_player() expose la valeur EFFECTIVE d'un
joueur au runtime (full_game_payload.py).

db/definitions/ + db/rows/ : chaque table d'objet généré
(create_definition.py) gagne une colonne player_id (ADD COLUMN simple,
pas de contrainte UNIQUE en jeu ici) ; _definitions gagne per_player.
Contrairement aux variables, PAS de repli sur une ligne "modèle" pour
les lignes d'un objet per_player — une LISTE n'a pas de valeur par
défaut unique à copier comme un scalaire, un nouvel objet per_player
démarre VIDE pour chaque joueur (nouveau resolve_row_player_key.py).
get_row/update_row/update_row_field/delete_row filtrent aussi par
player_id (pas seulement id) : garde-fou contre un row_id d'un AUTRE
joueur, nécessaire dès qu'un objet per_player sera exposé sur la future
route publique /jouer/<slug>. Migration : nouveau
ensure_player_id_column(slug, table_name), appelé avant toute requête
sur une table d'objet créée avant cette phase.

Chaîne de rendu (screens/elements/list_elements.py ->
screens/rendering/render_element_html.py -> render_repeater.py/
render_jauge.py/resolve_bound_row.py/visibility_condition.py/
filter_repeater_rows.py) : player_id transite dans le ctx déjà utilisé
partout pour "champ en cours" (ctx["_forge_player_id"], même patron que
ctx["_forge_play_mode"], posé une seule fois par list_elements quand
enforce_visibility=True) — pas de nouveau paramètre positionnel à
threader dans chaque fonction, juste une clé de plus dans un mécanisme
déjà en place.

Nouveau tests/test_player_state.py : verrouille à la fois le nouveau
comportement (deux joueurs => valeurs/lignes indépendantes ; per_player=0
=> partagé ; nouveau joueur => valeur par défaut pour une variable, liste
VIDE pour un objet ; get_row ne fuite jamais vers un autre joueur) et la
non-régression de l'aperçu créateur (comportement historique inchangé).

Vérifié : 232 tests passent (9 nouveaux). Reste à faire (prochains
commits) : route publique /jouer/<slug>, identité visiteur (cookie),
bascule "Publier en ligne" dans le tableau de bord.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:31:08 +02:00
williamandClaude Sonnet 5 bb84b7b377 Phase 0 : factorise compute_new_value + ajoute pytest/node test à la CI
Build and deploy / test (push) Failing after 13s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
1. Duplication éliminée avant que la Phase 2 (hasard/opérations
   mathématiques) n'en ajoute 6 de plus aux DEUX fichiers : la chaîne
   d'opérations quasi identique entre screens/data_actions/
   apply_data_action.py (champ d'objet) et apply_variable_action.py
   (variable globale) est factorisée dans un nouveau
   compute_operation.py::compute_new_value(operation, current, raw_value,
   is_decimal), réutilisé par les deux. Nouveau tests/test_compute_operation.py
   verrouille le comportement des 7 opérations existantes (dont les cas
   limites : valeur invalide, type décimal vs entier, opération inconnue)
   avant d'en ajouter d'autres.

2. .gitea/workflows/deploy.yml déployait en prod à chaque push sur main
   sans jamais exécuter la suite de tests — rien ne bloquait
   techniquement un commit cassé. Nouveau job "test" (pytest + node:test
   sur la logique pure de static/js/play/, via des conteneurs officiels
   plutôt que des actions du marketplace, cohérent avec le choix déjà
   fait dans ce fichier) tourne sur CHAQUE push (main ET dev, utile pour
   ce dépôt qui travaille sur dev) ; "build-and-push"/"deploy" gagnent un
   "needs: test" et restent réservés à main (filtre sur gitea.ref) — un
   push sur dev ne redéploie jamais la prod, seulement les tests.

Vérifié : 223 tests passent (8 nouveaux), YAML validé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:00:23 +02:00
williamandClaude Sonnet 5 fe807ba51e Phase -1 (suite) : découpe l'éditeur de screen_edit.html en modules JS
Même chantier que le commit précédent (moteur de jeu, play.html) —
templates/screen_edit.html était un unique fichier HTML+CSS+JS de 3312
lignes, tout l'éditeur (arborescence, panneaux flottants, canevas,
formulaire de propriétés, éditeur de flow à nœuds, blocs de logique,
timeline d'animation) vivant dans UN SEUL <script>.

Ce fichier est plus imbriqué que play.html : de nombreux appels
s'exécutent au niveau racine du script (pas seulement des déclarations
de fonctions), et JavaScript hoiste les déclarations `function` sur
TOUT le script — un appel au niveau racine peut donc référencer une
fonction déclarée PLUS LOIN dans le même fichier. Découper naïvement
casserait cet ordre implicite. Un audit dédié (analyse ligne par ligne
de chaque appel racine + son graphe d'appel transitif) a identifié 3
références "en avance" réelles, toutes regroupées dans la même zone
(initBuilderPanel()/toggleActionFields() → bindAspectButtons/
toggleElementPropertyValue/onDataDefinitionChange) — le découpage
respecte cette contrainte : chaque fichier est une TRANCHE SÉQUENTIELLE
de l'original (jamais une réorganisation), et cette zone spécifique
reste un seul fichier (panel-init.js) pour que le hoisting continue de
fonctionner exactement comme avant.

5 fichiers sous static/js/screen_edit/ :
- tree-panels.js — arborescence, menu contextuel, panneaux flottants
  gauche/droite, galerie d'icônes, modale de suppression/choix d'icône,
  glisser-déposer du canevas, panneau de propriétés (autosave).
- panel-init.js — (ré)initialisation du panneau central après chaque
  changement de sélection, filtres de répéteur/donnée liée, condition
  de visibilité, champs d'action du formulaire de nœud.
- flow-editor.js — éditeur de flow à nœuds (rendu du graphe, formulaire
  d'ajout de nœud, blocs de logique — currentBlockNodes/Edges).
- tabs-and-blocks.js — onglets du centre, panneaux flottants génériques
  (drag/resize/plein écran), modale d'un bloc de logique.
- animation-timeline.js — timeline d'animation (clips Animate.css/
  personnalisés).

Toutes les données injectées par Jinja (GAME_SLUG, SCREEN_ID,
DEFINITIONS_DATA, FLOW_NODES_INITIAL, ELEMENTS_LABELS, CUSTOM_EVENTS_MAP,
ANIM_CLIPS...) sont posées UNE FOIS par un petit <script> inline resté
dans le template, avant les <script src> — même patron que
static/js/play/. Le seul bout de logique resté inline est la toute
petite IIFE d'ouverture initiale (?tab=/?block=), qui dépend directement
de request.args et doit s'exécuter après que tous les fichiers soient
chargés.

tests/conftest.py : screen_edit_js_bundle() (même principe que
play_js_bundle(), Phase -1 précédente) — 4 tests qui vérifiaient la
présence de telle fonction/chaîne dans le HTML de l'éditeur (le JS y
était inline) sont mis à jour pour chercher dans ce bundle. Un des deux
échecs révélait un test déjà fragile (assert "Ligne cliquée" in html
vérifiait en réalité le TEXTE SOURCE d'un <script> inline, jamais du
HTML réellement rendu — ce texte ne peut plus s'y trouver une fois la
fonction qui le construit dynamiquement déplacée dans un fichier
externe) : corrigé pour vérifier le bundle JS + la disponibilité de la
route séparément.

Vérifié : 215 tests passent, syntaxe JS validée sur les 5 nouveaux
fichiers (node --check) et sur les <script> inline restants (rendus via
le client de test). Test manuel recommandé (édition complète d'une
scène : arborescence, propriétés, glisser-déposer, logique de flow,
blocs, timeline) avant de considérer ce découpage définitivement sans
risque — comme pour play.html, ce fichier n'a pas de harnais de test
DOM automatisé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 14:38:57 +02:00
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00
williamandClaude Sonnet 5 a445b72a6e Corrige "Ouvrir"/"Modifier" inertes dans l'onglet Blocs de logique
Deux bugs distincts, signalés par capture d'écran (bouton "Ouvrir" sans
effet, ligne d'édition affichée en permanence au lieu d'être cachée) :

1. onclick="openLogicBlockPanel({{ b.id }}, {{ b.name|tojson }})" cassait
   l'attribut HTML : tojson produit des guillemets DOUBLES (valides en
   JSON), qui terminaient prématurément l'attribut onclick="..." lui-même
   entre guillemets doubles — le gestionnaire de clic généré était donc
   tronqué et invalide, provoquant une erreur JS non interceptée qui
   arrêtait aussi tout le script restant dans la même balise <script>
   (dont l'IIFE qui devait poser window.openLogicBlockPanel). Corrigé en
   ne passant que l'id dans l'attribut et en retrouvant le nom du bloc
   côté client depuis FLOW_BLOCKS (déjà chargé) — plus aucune chaîne
   utilisateur à échapper dans un attribut HTML.

2. <tr class="hidden" id="blockEditRow..."> ne se cachait jamais : le
   CSS ne définissait .hidden que scopé (.floatPanel.hidden,
   .columnFilterMenu.hidden), jamais en règle générique — ajoutée dans
   styles/forge-custom.css.

Vérifié : 215 tests passent, syntaxe JS validée sur un scénario avec un
vrai bloc existant (reproduisant exactement la situation signalée),
onclick généré inspecté directement dans le HTML rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:48:25 +02:00
williamandClaude Sonnet 5 1b7706b357 Ajoute les blocs de logique : organise le graphe de flow en sous-graphes nommés
Le graphe de logique d'une scène s'affichait jusqu'ici sur un seul
canevas plat (toutes les scènes accumulant leurs nœuds sur la même
grille), ce qui ne tient pas à l'échelle dès qu'une scène évolue au fil
de l'avancée du joueur et accumule des centaines/milliers de nœuds.

Ajoute les "Blocs de logique" : un bloc regroupe un sous-ensemble de
nœuds/arêtes d'un écran sous un nom et une description (comme une
fonction). L'onglet "Logique de la scène" devient une liste de blocs
(nom, description tronquée à 3 phrases, éléments concernés, nombre de
nœuds, bouton "Ouvrir"). Ouvrir un bloc affiche SON graphe dans une
modale plein écran, redimensionnable et déplaçable (patron déjà mûr
dans game_dashboard.html, porté tel quel : makeFloatPanelDraggable/
Resizable/Fullscreenable).

Décision d'architecture : un bloc est un automate FERMÉ — impossible de
relier un nœud d'un bloc à un nœud d'un autre bloc (rejeté côté serveur
dans flow_edge_add.py). Toute communication entre deux blocs passe par
le système d'événements personnalisés déjà en place
(declencher_evenement / trigger_event="evenement").

Détails techniques :
- Nouvelle colonne _flow_nodes.block_id (nullable, sans FK — même
  rationale que trigger_element_id/target_element_id, voir
  screens/elements/delete_element.py) et nouvelle table _flow_blocks
  (screens/flow/ensure_flow_schema.py,
  screens/flow/blocks/ensure_flow_blocks_schema.py).
- Migration douce et automatique : les nœuds posés avant l'existence
  des blocs (block_id NULL) sont rattachés, à la première ouverture de
  l'onglet, à un "Bloc principal" auto-créé (screens/flow/blocks/
  list_flow_blocks.py) — aucun script de migration séparé, aucune
  donnée perdue.
- Suppression d'un bloc = cascade complète (bloc + tous ses nœuds/
  arêtes), patron identique à screens/custom_events/delete_custom_event.py
  mais scopé à un seul bloc plutôt que game-wide.
- Routes CRUD sous routes/flow_blocks/, montées comme routes/custom_events/.
- templates/screen_edit.html : FLOW (global unique) renommé en ALL_FLOW
  (toutes les données de l'écran) ; un seul bloc ouvert à la fois
  (modale unique, à la Unity) — currentBlockNodes()/currentBlockEdges()
  filtrent ALL_FLOW par CURRENT_BLOCK_ID à chaque rendu, sans tenir de
  seconde copie à synchroniser manuellement.

Vérifié : 215 tests passent (7 nouveaux dans tests/test_flow_blocks.py,
dont un qui verrouille l'ordre d'appel list_flow_blocks()/
list_flow_nodes() dans screen_edit.py — la migration douce doit tourner
AVANT le chargement des nœuds, sinon le compte de nœuds affiché juste
après une migration est périmé), syntaxe JS validée (script de
screen_edit.html rendu via le client de test puis node --check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:30:49 +02:00
williamandClaude Sonnet 5 a7a315cce7 Corrige un bug moteur : plusieurs déclencheurs "Au clic" (ou survol) sur le même élément n'exécutaient que le premier
Diagnostic effectué directement sur projects/test/game.db de
l'utilisateur (suite à son signalement "je ne parviens pas à mettre fin
à la surbrillance") : deux nœuds Déclencheur distincts ("Au clic",
id=4 et id=42) référençaient le même trigger_element_id=65. La fonction
findTriggerNode() utilisait Array.find(), qui ne retourne que la
PREMIÈRE correspondance — le second nœud (celui qui devait couper la
surbrillance) n'était donc jamais exécuté, silencieusement, quel que
soit le graphe construit dans l'éditeur.

Ce n'est pas un bug lié aux événements personnalisés ni aux conditions
par variable (deux pistes explorées avant ce diagnostic) : c'est une
limitation générale du moteur, qui n'a jamais géré plus d'un déclencheur
"Au clic"/"Au survol"/"À la fin du survol" par élément.

Renomme findTriggerNode() en findTriggerNodes() (pluriel) : retourne
désormais TOUS les nœuds correspondants (élément + type d'événement),
et bindClicks()/bindHoverTriggers() exécutent chaque flow trouvé au lieu
de s'arrêter au premier.

Vérifié : 208 tests passent, syntaxe JS validée (script de play.html
rendu via le client de test puis node --check). Comme pour le reste du
graphe de logique côté client, ce changement n'est pas couvert par les
tests automatisés (pas de harnais navigateur/DOM) — vérification
manuelle recommandée sur le scénario réel de l'utilisateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 10:26:37 +02:00
williamandClaude Sonnet 5 2c59e54556 Simplifie les événements : notification pure, sans paramètre
Retour de l'utilisateur sur le premier jet : "Déclencher un événement"
ne doit JAMAIS faire choisir un élément — c'est une notification pure,
rien de plus. C'est à l'ÉCOUTEUR (déclencheur "Sur un événement
personnalisé" → condition → action) de décider quoi faire ensuite, avec
ses réglages habituels (cible fixe, "Ligne cliquée"...), jamais à
l'événement de transporter un paramètre.

Retire donc tout le mécanisme de transmission ajouté au tour précédent
(has_element_param, target_element_from_event, EVENT_ROW_ID,
window.lastEventParams) :

- db/custom_events/ : _custom_events perd sa colonne has_element_param —
  un événement n'est plus qu'un nom + une description.
- screens/flow/ : retire target_element_from_event (colonne ajoutée par
  ALTER TABLE, laissée inerte sur les bases déjà migrées — sans
  conséquence, plus jamais lue ni écrite) et la constante EVENT_ROW_ID.
- routes/flow/flow_node_run_data.py : retire la résolution EVENT_ROW_ID,
  revient à sa forme d'origine (seul CLICKED_ROW_ID reste géré).
- templates/screen_edit.html : le nœud Action "Déclencher un événement"
  n'a plus qu'un sélecteur d'événement — plus de champs élément/ligne.
  Le nœud Action "Modifier un élément" perd la case "Utiliser l'élément
  transmis par l'événement en cours". L'onglet Événements perd la case
  à cocher "Paramètre" (création et édition).
- templates/play.html : window.dispatchGameEvent(eventId) ne prend plus
  que l'id de l'événement — scan global inchangé, mais ne pose plus
  aucun window.lastEventParams. modifier_element et readFieldValue
  reviennent à leur résolution d'origine (plus de branche event-aware).

208 tests au total (2 tests retirés, devenus sans objet : la
persistance de target_element_from_event et la résolution serveur
d'EVENT_ROW_ID).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 20:39:42 +02:00
williamandClaude Sonnet 5 666aa892e0 Corrige le ciblage élément+ligne transmis par un événement dans un Répéteur
Signalé par l'utilisateur : quand "Modifier un élément" utilise la case
"Utiliser l'élément transmis par l'événement en cours"
(target_element_from_event), et que cet élément vit à l'intérieur d'un
Répéteur, la résolution ne ciblait jamais que le PREMIER élément
correspondant trouvé dans toute la page — jamais forcément la bonne
ligne.

Vérifié directement sur un rendu réel : chaque ligne d'un Répéteur
rejoue le MÊME modèle (voir render_repeater.py), donc le MÊME
data-element-id se répète à l'identique sur CHAQUE .repeaterItem — seul
data-row-id (posé sur l'enveloppe .repeaterItem) distingue réellement
une ligne d'une autre. Un simple
document.querySelector('[data-element-id]') global tombe donc toujours
sur la première ligne rencontrée dans le DOM, sans rapport avec la ligne
réellement transmise par l'événement (window.lastEventParams.row_id).

Corrigé : quand l'événement transmet aussi une ligne, la recherche est
désormais scopée à l'intérieur du .repeaterItem[data-row-id=...]
correspondant avant d'y chercher l'élément — sinon (élément fixe, ou
événement sans paramètre de ligne), le comportement global d'avant reste
inchangé.

210 tests toujours verts (ce correctif est purement côté client, jamais
couvert par les tests automatisés — vérifié manuellement via un rendu
réel confirmant la structure .repeaterItem/data-row-id décrite
ci-dessus).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 20:23:11 +02:00
williamandClaude Sonnet 5 f436190f90 Ajoute les événements personnalisés (éditeur + exécution)
Deuxième moitié de la fonctionnalité "événements" (voir le commit
précédent pour le backend) : un nouvel onglet "📣 Événements" dans
screen_edit.html (scène ET modèle, même route) pour créer/modifier/
supprimer un événement (nom, description, "a un paramètre élément" oui/
non) et voir où il est déjà écouté/déclenché ; deux nouveaux types de
nœud dans le graphe de logique ; exécution réelle côté client
(play.html).

- templates/screen_edit.html :
  - Onglet Événements : barre de création + tableau (patron
    form/name="..." + attribut `form="eventEditFormN"` déjà utilisé par
    l'onglet Variables du tableau de bord, pour éditer plusieurs champs
    d'une ligne sans emboîter un <form> dans un <tr>). Colonne "Utilisé
    par" avec liens directs vers les écrans/modèles concernés
    (list_custom_event_usages, déjà en place côté backend).
  - Nœud Déclencheur "Sur un événement personnalisé" : un simple sélecteur
    d'événement (trigger_custom_event_id) — ni élément ni écran, retrouvé
    par un scan global côté client (comme "À l'affichage de l'écran").
  - Nœud Action "Déclencher un événement" : sélecteur d'événement
    (target_custom_event_id), avec élément/ligne concernés affichés
    seulement si l'événement a has_element_param (réutilise le sélecteur
    d'élément existant + "Ligne cliquée" pour la ligne, pas de définition
    d'objet ici donc pas de liste de lignes fixes possible).
  - Nœud Action "Modifier un élément" : nouvelle case "Utiliser l'élément
    transmis par l'événement en cours" (target_element_from_event) — v1,
    seule cette action l'expose (extensible plus tard sans nouveau
    changement de schéma).
  - nodeLabel()/submitNodeForm()/toggleFlowTriggerFields()/
    toggleFlowActionFields() étendus en conséquence, CUSTOM_EVENTS_MAP
    (id -> nom/has_element_param) exposé côté JS pour le rendu des
    libellés et l'affichage conditionnel des champs paramètre.

- templates/play.html :
  - window.dispatchGameEvent(eventId, elementId, rowId) : scan de
    gameData.flows (toutes scènes ET modèles à la fois, même principe que
    findTriggerNode()/runScreenShowTriggers()) pour trouver chaque
    écouteur, pose window.lastEventParams puis exécute son graphe
    (runFlowFrom) — au même titre que window.lastClickedRowId pour "Ligne
    cliquée".
  - runActionNode : nouvelle branche "declencher_evenement" (résout
    CLICKED_ROW_ID pour la ligne transmise, comme "Modifier une donnée")
    ; branche "modifier_element" étendue pour résoudre dynamiquement
    l'élément depuis window.lastEventParams quand
    target_element_from_event est actif.
  - readFieldValue (évaluation de condition) et l'appel serveur de
    "Modifier une donnée" (routes/flow/flow_node_run_data.py) résolvent
    désormais aussi EVENT_ROW_ID (-2), au même endroit que CLICKED_ROW_ID
    (-1) déjà en place.

- routes/screens/screen_edit.py : passe custom_events/
  custom_event_usages/custom_events_map_json au template (même patron
  que global_variables déjà threadé pour le sélecteur de variable dans
  le formulaire de Condition).

Nouveaux tests dans tests/test_custom_events.py : forme exacte du
payload runtime exposé au JS (types entiers, pas des chaînes — une
comparaison stricte "===" échouerait silencieusement sinon),
target_element_from_event bien persisté, résolution serveur d'
EVENT_ROW_ID depuis le corps de la requête. 210 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:34:24 +02:00
williamandClaude Sonnet 5 dcbec16818 Ajoute les événements personnalisés (backend) : déclencher/écouter
Première moitié de la fonctionnalité "événements" (NEED_ACTION et
autres) : une entité game-wide (nom, description, "a un paramètre
élément" oui/non), déclenchable comme nouvelle action du graphe de
logique depuis n'importe quelle scène/modèle, et écoutable comme
nouveau type de déclencheur depuis n'importe quel autre. L'UI (nouvel
onglet "Événements" dans screen_edit.html, formulaires de nœud, exécution
côté client dans play.html) suit dans un commit séparé.

- db/custom_events/ (calqué sur db/global_vars/) : CRUD de la table
  _custom_events (nom unique, description, has_element_param).
  create_custom_event est idempotent par nom (même convention que
  create_global_variable) — sans risque en cas de double soumission.
- screens/flow/ : 3 nouvelles colonnes sur _flow_nodes
  (trigger_custom_event_id/target_custom_event_id : quel événement un
  nœud écoute/déclenche ; target_element_from_event : indicateur
  réutilisable par n'importe quel nœud Action utilisant déjà
  target_element_id, pour résoudre "l'élément transmis par l'événement
  en cours" au lieu d'une cible fixe — contourne la contrainte de clé
  étrangère de target_element_id, qui empêche d'y stocker un sentinel
  comme EVENT_ROW_ID directement). Nouveau trigger_event "evenement" et
  action_type "declencher_evenement".
- screens/custom_events/ (PAS dans db/, même séparation que
  screens/elements/delete_element.py) : delete_custom_event, la SEULE
  suppression d'entité game-wide du moteur à vraiment cascader (demande
  explicite) — supprime tous les nœuds/arêtes qui référencent
  l'événement, sur TOUTES les scènes ET tous les modèles à la fois
  (aucun filtre screen_id nécessaire : un modèle est un écran caché,
  même table _flow_nodes). list_custom_event_usages : où un événement
  est écouté/déclenché, pour l'onglet Événements à venir.
- routes/custom_events/ : CRUD monté sous /game/<slug>/events/...,
  redirige vers l'éditeur de scène/modèle d'origine (screen_id transmis
  par le formulaire) avec l'onglet "events" à ouvrir.

tests/test_custom_events.py (nouveau) : idempotence à la création,
usages détectés sur deux écrans différents, suppression qui retire bien
les DEUX nœuds (un sur une vraie scène, un sur un modèle/écran caché)
en une seule opération, sans toucher aux écrans eux-mêmes. 207 tests au
total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 19:14:19 +02:00
williamandClaude Sonnet 5 1289079da5 Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).

Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.

- db/constants.py : PROJECTS_DIR devient surchargeable via
  FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
  joueur autonome pointe ainsi vers son propre dossier "projects/"
  embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
  cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
  embeddable officiel (python.org) et vendore Flask via pip --target ;
  fonctions séparées et mockables pour ne jamais déclencher de vrai
  téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
  core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
  dur au moment de la publication, csrf_token() factice puisqu'aucune
  session n'existe dans cet export). sys.path doit être complété
  manuellement au démarrage : le python311._pth de la distribution
  embeddable ne référence que le dossier de python.exe lui-même, jamais
  celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
  (jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
  réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
  garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
  de suivi serveur réel — le build est rapide) qui déclenche le
  téléchargement du zip via un blob, comme la modale des codes de
  récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
  pjax.js la remplace et rejoue son script à chaque navigation).

Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.

tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 09:58:32 +02:00
williamandClaude Sonnet 5 5d6747770c Auto-héberge les polices Google Fonts et animate.css (fin des CDN)
Étape 1 de la fonctionnalité "Publier un jeu en exécutable autonome" :
un jeu exporté devra fonctionner sans AUCUNE connexion internet, ce qui
suppose d'abord que l'app elle-même n'ait plus aucune dépendance CDN —
Bulma l'était déjà (refonte design system), il ne restait que Google
Fonts et animate.css, chargés par templates/play.html ET
templates/screen_edit.html (aperçu du canevas dans l'éditeur).

GOOGLE_FONTS_LINK (screens/widgets/font_options.py) était une constante
FIXE (4 polices, 2 graisses chacune, jamais dépendante du jeu en cours) :
téléchargées une fois pour toutes (45 fichiers woff2, ~1.1 Mo au total
avec la couverture cyrillique/vietnamienne incluse) dans
static/vendor/fonts/, avec un fonts.css régénéré à partir du CSS
officiel de Google Fonts mais pointant vers les fichiers locaux. Même
chose pour animate.min.css 4.1.1 (static/vendor/animate.min.css).

routes/play/game_play.py et routes/screens/screen_edit.py ne passent
plus google_fonts_link aux templates (devenu inutile, les deux pages
chargent directement les fichiers locaux) ; GOOGLE_FONTS_LINK est retiré
de screens/widgets/font_options.py et screens/__init__.py (plus aucun
appelant).

tests/test_animations.py : les deux tests qui vérifiaient la présence
du lien CDN vérifient maintenant la présence du fichier local
(animate.min.css) — renommés en conséquence.

199 tests toujours verts. Reste à faire pour la fonctionnalité complète :
empaquetage Python portable + serveur minimal + bouton "Publier".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 09:29:51 +02:00
williamandClaude Sonnet 5 6b9a491387 Corrige l'enveloppe ouverte/fermée affichées en même temps (bug préexistant)
Ce bug n'est PAS lié à la refonte design system de cette session — le
fichier concerné (templates/play.html) n'avait plus été touché depuis
une session précédente (commit ed4dd78), bien avant les changements de
CSS/Bulma. Vérifié en cherchant "les données ont-elles été perdues ?" :
non, tous les objets/champs restent intacts en base (projects/test/game.db).

Root cause réel : refreshRuntimeData()::findCommentMarkedDescendants()
ne repérait, dans le HTML fraîchement régénéré après une action
"Modifier une donnée", que les éléments qui REDEVIENNENT VISIBLES sous
condition (repérés via le commentaire "<!--visibilityGated-->" posé après
leur contenu réel par _mark(), voir render_element_html.py) — jamais ceux
qui REDEVIENNENT CACHÉS, dont le placeholder ('<div class=
"visibilityGated" ... style="display:none;">') n'a pas de commentaire à
sa suite, juste une classe sur lui-même. Résultat : l'élément qui
redevient visible est bien patché dans le DOM, mais l'ancien élément
visible n'est jamais retiré — les deux restent affichés en même temps
(ex. "enveloppe fermée" jamais masquée à côté de "enveloppe ouverte" qui
apparaît après un clic).

Corrigé en faisant aussi reconnaître, dans findCommentMarkedDescendants(),
la classe "visibilityGated" directement sur la balise (cas caché), en
plus du commentaire (cas visible) — les deux sens du bascule sont
maintenant retrouvés et patchés.

tests/test_visibility_toggle_markers.py (nouveau) verrouille le contrat
côté serveur dont dépend ce correctif JS : un élément cité verrouille que
l'élément CACHÉ porte bien la classe (jamais de commentaire), l'élément
VISIBLE porte bien le commentaire (jamais la classe), et que ces rôles
s'inversent correctement quand la donnée change. 199 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:55:23 +02:00
williamandClaude Sonnet 5 c60884ec46 Corrige une régression fonctionnelle : revient à Bulma 1.0.2 (plus de Sass)
L'utilisateur a signalé des bugs réels en jeu après la refonte design
system : jauges qui ne se remplissent plus, conditions de visibilité
cassées sur une carte (enveloppe ouverte/fermée affichées en même temps).
Racine du problème : Phase A avait basculé Bulma de 1.0.2 (CDN d'origine)
vers 0.9.4, seule version compatible avec libsass (1.0.2 utilise le
système de modules @use/@forward que libsass ne sait pas compiler). Or
screens/rendering/render_jauge.py pilote le remplissage d'une jauge en
fixant en ligne --bulma-progress-value-background-color — une variable
CSS qui n'existe QUE dans le nouveau système de theming de Bulma 1.x,
absente de 0.9.4. Aucune perte de données : les champs d'objet étaient
toujours intacts en base (vérifié directement sur projects/test/game.db)
— uniquement un problème de rendu/comportement en jeu.

Correction : revient à Bulma 1.0.2, vendoré tel quel et non modifié
(static/vendor/bulma.min.css, ~677 Ko, auto-hébergé — toujours aucune
dépendance CDN). Bulma 1.x expose déjà tout son thème via de vraies
variables CSS (--bulma-primary-h/-s/-l, --bulma-radius...), justement
conçues pour être surchargées après coup SANS recompilation Sass —
styles/bulma-override.css les redéfinit avec la palette Forge (teintes
HSL calculées à partir des couleurs de la charte). build_css.py devient
un simple concaténage de 4 fichiers (bulma.min.css + bulma-override.css
+ forge-tokens.css + forge-custom.css), plus besoin de libsass ni
d'aucun compilateur — supprimé de requirements.txt. styles/bulma/ (source
Sass 0.9.4 vendorée en Phase A) et styles/forge-theme.scss supprimés.

Nouvelle règle ajoutée en commentaire dans bulma-override.css : ne plus
jamais changer de version de Bulma sans `grep -rn "\-\-bulma-" screens/
templates/` d'abord — cette dépendance n'est pas que visuelle.

197 tests toujours verts (ils ne couvrent que le HTML généré, jamais le
rendu réel — c'est pour ça que cette régression n'avait pas été détectée
avant que l'utilisateur ne la signale en jouant pour de vrai).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:40:16 +02:00
williamandClaude Sonnet 5 eaebc555d3 Refonte design system — Phase D : éditeur de scène et aperçu jouable
L'essentiel du re-skin de l'éditeur de scène (onglets, panneaux flottants,
groupes de propriétés, boutons, galerie d'icônes, menu contextuel, modales)
était déjà obtenu automatiquement par le passage global des tokens en
Phase A — tout ce CSS custom consommait déjà les mêmes variables
(--panel/--border/--accent...) que le reste de l'app. Reste ce sweep
ciblé des dernières couleurs codées en dur qui échappaient aux tokens :

- screen_edit.html : bandeau "élément de jeu réutilisable" (fond/bordure
  panneau en dur), avertissement de suppression de nœud de logique
  (fallback --danger périmé), séparateur de clause de condition (gris
  clair #ccc, incohérent sur un thème exclusivement sombre) — tous
  reliés à var(--panel)/var(--border)/var(--danger). Les couleurs de
  swatch par défaut des actions "changer la couleur d'un élément"
  (#5b8cff, #ff0000) sont volontairement laissées telles quelles : ce
  sont des valeurs de CONTENU (le jeu du client), pas du chrome Forge.
- play.html : #emptyState (message "aucun écran" généré par le moteur,
  pas du contenu du jeu) relié à var(--forge-text-muted). Le rendu du
  jeu lui-même (fond du cadre, bordure des champs de saisie posés par le
  client sur ses écrans) reste strictement inchangé, conformément à la
  distinction actée avec l'utilisateur entre chrome de l'outil et
  contenu du jeu créé.
- Aucun changement à la disposition (canvas, floatPanel, builder3) —
  conforme à l'exemption du §5/§9 du document de règles.

197 tests toujours verts ; JS de screen_edit.html (très long) revérifié
via une extraction jetable + node --check, comme pour les phases
précédentes.

Termine la refonte design system en 4 phases (voir
regles/FORGE_ENGINE_TEMPLATE_BULMA.md) : Bulma auto-hébergé compilé via
Sass avec la palette Forge, chrome global et pages d'authentification,
en-têtes de page des écrans de gestion, éditeur de scène et aperçu
jouable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:14:33 +02:00
williamandClaude Sonnet 5 9157b16038 Refonte design system — Phase C : en-tête de page (accueil, tableau de bord)
- index.html ("Mes jeux") : le <h1> nu est remplacé par l'en-tête de page
  compact du §5 (.dashPanelHeader, déjà utilisé ailleurs pour ce
  patron) — titre + badge du nombre de jeux (.forge-badge) sur une seule
  ligne. Pas de second CTA : le formulaire de création reste dans sa
  colonne dédiée, un bouton de plus ferait doublon.
- game_dashboard.html : ajout du même en-tête, absent jusqu'ici (la page
  démarrait directement sur la barre d'onglets) — nom du jeu + chemin du
  dossier, pour savoir sur quel jeu on se trouve sans avoir à regarder
  l'URL.
- styles/forge-custom.scss : .content-objectEdit > h1/.hint/form/
  .warningBanner (règle qui fige la hauteur des enfants directs non
  extensibles dans la mise en page flex de l'accueil) étendue à
  .dashPanelHeader, qui remplace maintenant le <h1> direct.
- profile.html : déjà conforme (rayons de boîte/bouton, danger tokenisé)
  depuis le passage global des tokens en Phase A — aucun changement
  supplémentaire nécessaire.

197 tests toujours verts ; vérification ciblée (fixture jetable, supprimée
ensuite) confirmant l'en-tête et le JS du tableau de bord.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:08:55 +02:00
williamandClaude Sonnet 5 62d25dbdce Refonte design system — Phase B : chrome global et pages d'authentification
- styles/forge-custom.scss : .content-wide (déjà utilisée par
  game_dashboard.html/index.html) passe de 1080px à 1600px, pleine
  largeur pour les écrans de gestion (§5) — la largeur du bloc <main> par
  défaut (760px, hérité par les pages d'auth/profil qui ne posent pas
  cette classe) reste inchangée, ces pages veulent justement rester
  étroites et centrées.
- Pages d'authentification (login/register/2FA×2/mot de passe oublié/
  réinitialisation) : le style="max-width:NNNpx" répété sur chacune est
  remplacé par les classes partagées .authScreen/.authCard(-wide), titre
  H1 en dégradé signature .forge-gradient-title — seul endroit du site,
  avec le logo, autorisé à l'utiliser (§3/§5). Grille de fond fine sur
  body.authBody, elle aussi réservée aux zones hero et absente des écrans
  de travail.
- Logique JS inchangée (jauge de mot de passe dans register.html/
  reset_password.html) — uniquement des classes/structure autour.

197 tests toujours verts, JS des pages modifiées revérifié (node --check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:05:27 +02:00
williamandClaude Sonnet 5 f2a73f2c73 Refonte design system — Phase A : Bulma auto-hébergé compilé via Sass
Met en place les fondations du template Bulma décrit dans
regles/FORGE_ENGINE_TEMPLATE_BULMA.md : palette orange/ambre Forge
(--accent:#ff5f2e/--accent-2:#ffb020), typographie, rayons — appliqués
en recompilant Bulma lui-même plutôt qu'en le surchargeant après coup en
CSS, pour recolorer automatiquement ses composants internes (tags,
notifications, dropdowns...) sans avoir à les surcharger un par un.

- Bulma 0.9.4 vendoré en local (styles/bulma/, source Sass classique
  $variable + @import) — PAS 1.0.2 (la version jusqu'ici en CDN) : 1.0.2
  utilise le nouveau système de modules @use/@forward, que libsass (choisi
  pour rester 100% Python, sans Node/npm) ne sait pas compiler (testé :
  il ignore silencieusement le @use au lieu de le traiter). 0.9.4 est la
  dernière version compatible avec libsass et couvre à l'identique tous
  les composants utilisés ici (boutons, tableaux, onglets, modales,
  formulaires, navbar).
- requirements.txt : +libsass (pip pur, aucun binaire/Node.js).
- styles/forge-theme.scss (nouveau, point d'entrée) : variables Sass
  Bulma ($primary, $radius...) posées avant l'import, tokens Forge exposés
  en :root (--forge-bg, --accent, --gradient, --status-*...) avec des
  alias vers les noms de variables déjà utilisés par tout le CSS custom
  existant (--bg/--panel/--border/--text/--danger...) — pas besoin de
  renommer les ~600 lignes de règles déjà écrites, seules leurs VALEURS
  changent.
- styles/forge-custom.scss : ancien static/style.css, structurellement
  inchangé — seuls les hex/rgba en dur qui échappaient aux variables
  (ancien accent bleu #5b8cff, danger #e2685f, couleurs de types de
  nœuds du graphe de logique, fond du QR code recovery...) sont
  remplacés par les tokens de la charte.
- build_css.py (nouveau) : compile styles/forge-theme.scss en
  static/style.css via libsass — un seul fichier, un seul <link>
  inchangé dans les templates, à relancer manuellement après toute
  modification sous styles/.
- base.html/play.html : suppression du <link> CDN Bulma (auto-hébergé
  désormais), ajout d'un favicon (absent jusqu'ici) et du vrai logo Forge
  dans la navbar (assets/*.svg copiés dans static/branding/, seul dossier
  réellement servi par Flask).

197 tests toujours verts (aucune assertion sur des valeurs CSS).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 08:01:32 +02:00
williamandClaude Sonnet 5 dedbda422c Rend la page de profil compacte et sur 2 colonnes
Les 5 sections (email, informations, mot de passe, codes de récupération,
suppression du compte) tenaient les unes sous les autres dans une seule
colonne étroite (560px) avec le padding/espacement par défaut de Bulma —
imposait de scroller pour tout voir. Réorganisé en grille CSS 2 colonnes
(container élargi à 980px, static/style.css : .profileGrid/.profileCol/
.profileBox) : compte/infos/suppression à gauche, sécurité (mot de passe,
codes de récupération) à droite. Champs resserrés (labels remplacés par
des placeholders, is-small partout, marges réduites), jauge de force du
mot de passe sur une ligne (.passwordChecklist-compact). Repasse en une
seule colonne sous 720px de large.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 06:46:19 +02:00
williamandClaude Sonnet 5 c8c949c430 Ajoute la régénération des codes de récupération et le changement d'email
Depuis /profile :
- "Codes de récupération 2FA" : régénère les 10 codes (invalide les 10
  précédents d'un coup, voir auth.generate_recovery_codes) et les affiche
  une seule fois via le même modal qu'à l'inscription (session flash,
  core/recovery_codes_flash.py) — mot de passe actuel requis.
- "Adresse email" : change l'adresse (mêmes règles qu'à l'inscription —
  format valide, unicité — voir auth/email_validation.py, désormais
  partagé avec create_user.py au lieu d'être dupliqué). Pour un compte
  "user", le dossier de son unique projet porte le nom de son adresse
  (project_slug = slugify(email)) : il est renommé pour suivre le
  changement (db/games/move_game.py, même logique d'unicité par suffixe
  que create_game()), sans quoi project_slug ne correspondrait plus à
  aucun dossier réel. Un "admin" n'a pas de project_slug dédié : rien
  n'est renommé pour ce rôle.

18 nouveaux tests (tests/test_profile.py) : mauvais mot de passe refusé
pour les deux actions, mauvais format/email déjà pris refusés, dossier
bien renommé et project_slug mis à jour, connexion possible avec la
nouvelle adresse, anciens codes de récupération bien invalidés après
régénération, modal jamais réaffiché deux fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 06:29:38 +02:00
williamandClaude Sonnet 5 5c2d7c49cd Corrige l'affichage des codes de récupération 2FA (invisibles en pratique)
Le modal était placé en dehors de #pageChrome ET de <main> — or pjax.js
(swapDocument()) ne remplace jamais que ces deux conteneurs à chaque
navigation, jamais le body entier. La redirection qui suit la
confirmation de la 2FA passe par une navigation pjax (fetch), pas un
vrai rechargement de page : le HTML du modal était bien renvoyé par le
serveur, mais jamais copié dans le DOM réellement affiché, donc jamais
visible pour un utilisateur qui vient de s'inscrire.

Déplacé à l'intérieur de <main class="content"> : fait maintenant partie
de innerHTML remplacé par pjax.js à chaque navigation, comme le reste du
contenu de page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 06:18:00 +02:00
williamandClaude Sonnet 5 0d46abe193 Ajoute une page de profil (modifier ses infos, mot de passe, supprimer son compte)
Le nom affiché dans la barre de navigation (base.html) devient un lien
vers /profile : informations du compte (email en lecture seule, nom/
prénom modifiables), changement de mot de passe (mot de passe actuel
requis + mêmes règles de force qu'à l'inscription/la réinitialisation),
et une section "danger" pour demander la suppression du compte.

La suppression exige le mot de passe actuel ET la saisie exacte de
"SUPPRIMER" (deux confirmations distinctes pour une action irréversible)
— routes/auth/profile.py. Pour un compte "user" (limité à un seul projet,
créé automatiquement et impossible à supprimer autrement, voir
core/auth_guard.py), supprimer le compte supprime aussi son unique projet
sur le disque, faute de quoi il resterait orphelin sans plus aucun
propriétaire. Un compte "admin" peut posséder plusieurs jeux qui ne lui
sont pas dédiés de la même façon : ses projets ne sont jamais touchés.
L'unique compte administrateur ne peut pas être supprimé (auth/
count_admins.py) — le supprimer bloquerait la création d'un nouveau
compte admin (réservée au tout premier compte jamais créé, base vide).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:03:47 +02:00
williamandClaude Sonnet 5 d5a84413d1 Ajoute la réinitialisation de mot de passe par email (SMTP)
Un lien "Mot de passe oublié ?" (login.html) mène à /forgot-password :
si l'adresse saisie correspond à un compte, un jeton à haute entropie
(secrets.token_urlsafe, valable 1h) est généré et envoyé par email via
smtplib (auth/send_email.py, aucune dépendance ajoutée) — configuré
uniquement par variables d'environnement (SMTP_HOST/PORT/USER/PASSWORD/
FROM, voir .env.example et docker-compose.prod.yml), n'importe quel
serveur SMTP existant convient (Mailcow compris).

Seul le hash SHA-256 du jeton est stocké (auth/password_reset.py, table
_password_reset_tokens) : un jeton envoyé par email reste inutilisable
même en cas de fuite de la base. Le même message générique s'affiche que
l'adresse corresponde à un compte ou non, pour ne jamais permettre à ce
formulaire de servir à deviner quelles adresses sont déjà inscrites. Un
échec d'envoi (SMTP non configuré) est journalisé côté serveur seulement,
jamais révélé à l'utilisateur.

/reset-password/<token> vérifie le jeton (non expiré, non déjà utilisé),
applique les mêmes règles de mot de passe fort qu'à l'inscription (même
schéma visuel), puis consomme le jeton et remet à zéro le compteur
anti-bruteforce du compte (auth/set_password.py) — une identité prouvée
par email est une voie de récupération légitime même pour un compte
verrouillé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:50:49 +02:00
williamandClaude Sonnet 5 9efe119936 Ajoute des codes de récupération 2FA (perte du téléphone)
10 codes à usage unique (format "xxxx-xxxx-xxxx") sont générés à
l'instant même où la 2FA est confirmée (auth/recovery_codes.py, table
_recovery_codes séparée pour marquer/consommer chaque code un par un) —
seul leur hash (werkzeug, comme les mots de passe) est stocké, ils ne
sont visibles en clair qu'à cet instant précis.

Plutôt que d'interrompre la redirection habituelle après confirmation de
la 2FA, les codes sont posés en session ("recovery_codes_to_show") et
affichés une seule fois, en modal, dès le premier rendu de base.html qui
suit (core/recovery_codes_flash.py, session.pop) — préserve tel quel le
comportement de redirection déjà couvert par les tests existants.

Sur /login/2fa, un code de récupération est accepté à la place du code
TOTP habituel (routes/auth/login_2fa.py) : verify_totp est essayé en
premier (verify_recovery_code consomme le code dès qu'il correspond, on
ne veut pas en griller un pour rien sur une saisie qui aurait en fait
été un TOTP valide). Compte toujours vers le même compteur anti-bruteforce
que le code TOTP (déjà en place, voir auth/rate_limit.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:42:39 +02:00
williamandClaude Sonnet 5 a52b244f27 Ajoute la protection CSRF sur tous les formulaires et requêtes AJAX
Un jeton unique par session (core/csrf.py, exposé côté Jinja via
csrf_token()) est vérifié sur toute requête non-GET par un before_request
(core/csrf_guard.py), dans le même esprit que core/auth_guard.py : une
seule garde globale plutôt que de toucher aux ~90 routes existantes une
par une.

L'app entière fait déjà transiter ses formulaires par fetch() : pjax.js
intercepte chaque <form> interne et le transforme lui-même en requête
fetch (aucun usage de l'attribut d'échappement data-no-pjax nulle part
dans le repo, confirmé par grep). Il suffit donc de patcher window.fetch
UNE SEULE FOIS (static/csrf_fetch.js) pour y ajouter automatiquement
l'en-tête X-CSRFToken sur toute requête non-GET, formulaires pjax comme
fetch() écrits à la main dans screen_edit.html/game_dashboard.html/
play.html — sans modifier un seul appel existant.

La vérification est désactivée quand app.config["TESTING"] est actif
(même convention que Flask-WTF/WTF_CSRF_ENABLED), pour ne pas avoir à
ajouter le jeton aux ~170 tests existants qui appellent les routes
directement via le client de test Flask. tests/test_csrf.py réactive
volontairement la garde pour la mettre à l'épreuve pour de vrai (GET
jamais bloqué, POST sans jeton/avec mauvais jeton -> 400, POST avec le
bon jeton via l'en-tête ou le champ de formulaire -> succès).

templates/play.html reçoit les mêmes deux balises que base.html car il
est autonome (ne l'étend pas, propre <html>/<head>).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:35:32 +02:00
williamandClaude Sonnet 5 989e899a96 Ajoute un anti-bruteforce (3 essais libres puis 5/10/20/40 min, plafonné à 1h)
Sur la connexion (mot de passe + code 2FA, même compteur pour les deux —
un attaquant qui connaît le mot de passe ne doit pas avoir un nombre
illimité d'essais sur le code) et sur la confirmation 2FA de
l'inscription : 3 tentatives libres, puis un verrouillage qui double à
chaque nouvel échec (5, 10, 20, 40 minutes...), plafonné à 1h
(auth/rate_limit.py). Remis à zéro dès une connexion RÉELLEMENT aboutie
(mot de passe ET code corrects) — jamais sur le seul succès du mot de
passe, pour ne jamais donner un nombre illimité d'essais sur le 2FA à qui
connaît déjà le mot de passe.

Le verrouillage est annoncé IMMÉDIATEMENT sur la réponse qui le déclenche
(record_failed_attempt renvoie la durée qu'il vient de poser), pas
seulement découvert au prochain essai. Colonnes ajoutées en ALTER TABLE
(failed_attempts, locked_until) pour ne rien casser sur une base de
comptes déjà créée avant cette fonctionnalité.

14 tests dans test_auth.py (dont l'escalade 5/10/20/40/60, le blocage
même avec le bon mot de passe une fois verrouillé, et la remise à zéro
sur connexion réussie). 169 tests au total, tous au vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:09:35 +02:00
williamandClaude Sonnet 5 3032b27740 Corrige le QR code de la 2FA invisible à l'inscription
Deux bugs cumulés dans auth/totp_qrcode_svg.py :
- SvgImage (variante utilisée) ne pose aucun attribut viewBox sur le
  <svg> racine — la règle CSS qui le fait tenir dans son cadre
  (.totpQrWrap svg { width:100% }) n'avait donc rien à quoi se raccorder
  pour mettre à l'échelle le dessin interne (coordonnées en mm) : le QR
  code restait invisible/coupé. Remplacé par SvgPathImage, seule variante
  pure Python de qrcode dont le <svg> racine inclut un viewBox.
- qrcode.make() préfixe toujours sa sortie d'une déclaration XML
  ("<?xml version=...?>"), valide pour un fichier .svg autonome mais
  invalide au milieu d'un document HTML — ne garder que ce qui commence
  à "<svg" avant de l'insérer dans la page.

165 tests toujours au vert. Le compte non confirmé resté bloqué sur cet
écran (vandal.william@forgebase.fr) a été supprimé de data/users.db : la
prochaine inscription redevient bien le tout premier compte (admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:50:37 +02:00
williamandClaude Sonnet 5 d90abc827b Ajoute l'authentification : inscription, mot de passe fort, 2FA obligatoire, isolation par utilisateur
Première des deux grandes fonctionnalités demandées (authentification
d'abord, export HTML/CSS/JS autonome ensuite) :

- Inscription (nom, prénom, email UNIQUE, mot de passe) avec schéma
  visuel du mot de passe (jauge + liste de critères qui passent au vert
  en direct — auth/password_strength.py, mêmes règles vérifiées côté
  serveur qu'affichées côté client).
- Double authentification (TOTP, compatible Google Authenticator/Authy)
  OBLIGATOIRE dès l'inscription : QR code (SVG, sans dépendance Pillow)
  à scanner puis code à confirmer avant que le compte soit utilisable —
  voir auth/create_user.py (totp_confirmed) et routes/auth/register_2fa.py.
- Connexion en 2 temps (mot de passe puis code TOTP), déconnexion.
- Isolation par utilisateur : un compte "user" est limité à un SEUL
  projet, dont le dossier est nommé d'après son adresse email (slugifiée)
  et créé automatiquement dès la 2FA confirmée — aucune page de gestion
  multi-jeux pour lui (redirigé directement vers son propre tableau de
  bord). Le rôle "admin" reste illimité, comme le moteur l'a toujours été
  (le TOUT PREMIER compte jamais créé sur une base de comptes vide devient
  automatiquement admin — voir auth/is_first_user.py — pas de mot de
  passe par défaut à faire circuler : s'inscrire en premier suffit).
  Un compte "user" ne peut pas non plus supprimer son unique projet
  (aucune façon d'en recréer un ensuite).
- Garde d'accès globale (core/auth_guard.py, un seul before_request) :
  toute page exige une connexion, sans avoir touché individuellement aux
  ~80 routes déjà existantes du moteur.

tests/conftest.py isole complètement les tests de la vraie base de
comptes (FORGE_USERS_DB_PATH/FORGE_SECRET_KEY_PATH vers un dossier
temporaire propre à la session de tests) et authentifie automatiquement
la fixture `client` partagée en tant que compte admin de test — les 155
tests déjà existants continuent de passer SANS AUCUNE modification de
leur côté, exactement comme avant l'authentification. 10 nouveaux tests
dédiés (tests/test_auth.py) : inscription/mots de passe/2FA/connexion/
isolation par projet/blocage de suppression, vérifiés en conditions
réelles (vrai client de test Flask, vraie base SQLite, vrais codes TOTP
calculés avec pyotp). 165 tests au total, tous au vert.

Nouvelles dépendances : pyotp, qrcode (requirements.txt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:30:43 +02:00
williamandClaude Sonnet 5 ed4dd78dad Corrige le clignotement de la boîte de dialogue entière au lieu du seul texte
Suite du correctif précédent (124f250, marqueur "dataBound") : le
rafraîchissement fonctionnait, mais remplaçait tout l'élément de PREMIER
NIVEAU marqué — pour un Texte "Donnée liée" posé DANS un élément de jeu
réutilisable (ex. une boîte de dialogue), le seul élément de premier
niveau existant est l'EXEMPLAIRE lui-même (ses descendants internes ne
sont jamais des entrées séparées de la scène, voir list_elements.py) :
tout l'exemplaire — voile plein écran, boîte, tout — était donc remplacé
d'un bloc, ce qui le faisait visuellement disparaître puis réapparaître
pour un simple changement de texte à l'intérieur.

Correctif (templates/play.html, refreshRuntimeData()) : avant de
remplacer un élément marqué en bloc, on cherche d'abord, DANS le nouveau
fragment, des descendants plus précis portant eux-mêmes un marqueur en
commentaire ("visibilityGated"/"dataBound" — jamais "repeaterItem"/
"jaugeBar", qui restent volontairement régénérés en bloc, un
comportement déjà correct pour un Répéteur/une Jauge). S'il en existe,
seuls CES éléments précis sont patchés individuellement (retrouvés via
leur propre data-element-id) ; sinon, comportement inchangé (remplace
l'élément entier, cas normal d'un Texte "Donnée liée" posé directement
sur une scène, hors élément de jeu réutilisable).

155 tests toujours au vert (changement purement côté client — le
rendu serveur et la présence du marqueur, eux, étaient déjà couverts par
les tests du commit précédent). Vérifié structurellement (adjacence
</p><!--dataBound--> confirmée dans le rendu réel du projet "test").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:58:50 +02:00
williamandClaude Sonnet 5 124f250d2b Corrige le contenu de "Donnée liée" figé après un changement de variable/donnée
Bug rapporté : changer une variable globale utilisée comme valeur de
comparaison d'une "Donnée liée" (Texte/Titre, dans une boîte de dialogue
posée comme élément de jeu réutilisable) recalculait bien, côté SERVEUR,
la bonne ligne à afficher — mais le contenu affiché en jeu restait figé
sur son ancienne ligne tant que la page n'était pas complètement
rechargée.

Cause : templates/play.html ne régénère, après une action "Modifier une
variable"/"Modifier une donnée", QUE les éléments dont le rendered_html
porte un marqueur connu (voir refreshRuntimeData()/hasMarker() —
"repeaterItem", "jaugeBar", "visibilityGated"). Un Texte/Titre "Donnée
liée" n'en portait AUCUN : jamais identifié comme "dépendant de la
donnée", donc jamais régénéré, même si le nouveau HTML était déjà prêt
côté serveur à chaque rendu.

Correctif : nouveau marqueur "dataBound" (render_element_html.py), posé
dès que _data_definition_id est réglé, reconnu par hasMarker(). Un
exemplaire d'élément de jeu (ex. la boîte de dialogue posée sur une
scène) porte ce marqueur EN PROFONDEUR dans son propre rendered_html dès
qu'un de ses descendants internes en a un — il se retrouve donc bien
régénéré dans son ensemble, sans changement supplémentaire nécessaire.

Deux nouveaux tests, confirmés en échec sur l'ancien code (même scénario
que le rapport : reproduit avec objet "dialog" + variable "dialog_order"
+ élément de jeu réutilisable) puis au vert avec le correctif. 155 tests
au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:47:12 +02:00
williamandClaude Sonnet 5 8cffbeac68 Donnée liée : conditions ET/OU illimitées + conditions de logique sur une variable globale
Deux fonctionnalités demandées, développées et corrigées dans cet
échange :

1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
   combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
   choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
   screens/clause_list_codec.py). Rétrocompatible avec les anciens
   éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
   volée à la lecture, sans migration. Après un premier essai à la
   présentation trop compacte et technique (retour utilisateur : "pas de
   champ technique, pas de notation bizarre {{ }}"), la présentation
   finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
   "...cette valeur", même sélecteur de valeur fixe/dynamique/variable
   déjà existant, jamais la syntaxe brute), simplement répétée par
   condition (templates/partials/clause_row.html), avec un bouton
   "+ Ajouter une condition" bien visible et une liste scrollable
   (static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
   rows.py généralisé) profite aussi au Répéteur de données en interne.

2. Nœud Condition de la Logique de la scène : peut désormais tester une
   VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
   cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
   clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
   CLIENT (templates/play.html, evaluateConditionClause), contre un
   nouveau gameData.variables exposé par full_game_payload.py — tenu à
   jour par refreshRuntimeData() après toute action qui modifie une
   variable, sans changement supplémentaire nécessaire. Le panneau de
   condition reste utilisable même sans aucun objet défini dans le jeu
   (avant, il disparaissait entièrement).

Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:05:02 +02:00
williamandClaude Sonnet 5 7c237d6f1c Retire le voile plein écran et le forçage de visibilité de l'éditeur (retour utilisateur)
Deux retours après le dernier correctif (78a373a, qui forçait
"display:flex" dans l'éditeur pour que la boîte de dialogue reste
visible) :
- "je souhaite avoir la main sur la visibilité de la modale sinon elle
  s'affiche toujours sur la scène, court-circuite ma logique" — le
  forçage empêchait de vraiment utiliser "Visibilité" pendant l'édition.
- "quand j'édite la modale ou quand je la mets dans une scène je
  souhaite que rien ne soit assombri, l'assombrissement ne se fait que
  quand la scène est jouée" — le voile plein écran (position:fixed +
  fond assombri) restait aussi actif dans l'éditeur.

Correctif (render_overlay.py) : le voile plein écran ET le forçage de
visibilité sont retirés de l'ÉDITEUR — ce widget s'y comporte maintenant
comme un CONTENEUR NORMAL (position/taille selon x/y/width/height, aucun
voile, réglage "Visibilité" respecté normalement, comme n'importe quel
autre widget masqué). Le comportement plein écran/voile/masquage par
défaut n'est conservé qu'en mode JOUABLE (ctx["_forge_play_mode"]).

En creusant pourquoi "Visible" ne suffisait pas à faire réapparaître la
boîte dans l'éditeur (menant l'utilisateur à essayer "Invisible" à la
place, visible dans ses captures), trouvé un vrai bug latent dans
save_element_controls.py : l'option "Visible" du réglage "Visibilité" ne
touche volontairement jamais "display" (pour ne pas écraser le
"display:flex" d'un conteneur en disposition ligne/colonne — voir
visibility_control.py) — ça fonctionne seulement parce que, pour un
widget AVEC un réglage "Disposition interne", celui-ci réaffirme lui-même
un display non-"none" au même enregistrement. La "superposition" n'a PAS
ce réglage : "display:none" (posé à la création ou par un "Masqué"
précédent) restait donc bloqué pour toujours, quel que soit le nombre de
fois où "Visible" était ensuite choisi. Corrigé : "Visible" efface aussi
"display" pour tout widget SANS réglage "Disposition interne" (safe : les
widgets qui EN ont un ne sont pas concernés, donc aucune régression sur
leur comportement existant).

Tests mis à jour (l'ancien test attendait le forçage, désormais retiré) +
nouveau test qui couvre le cycle complet (masqué par défaut -> "Visible"
choisi -> apparaît sans voile dans l'éditeur -> voile plein écran
retrouvé en mode jouable). 140 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 07:14:51 +02:00
williamandClaude Sonnet 5 6e46949cf8 Corrige la boîte de dialogue posée comme élément de jeu réutilisable : conteneur vide au lieu du dialogue
Bug rapporté : poser un élément de jeu ("Mes éléments de jeu") dont le
modèle n'est qu'une "Superposition / boîte de dialogue" sur une vraie
scène affichait un conteneur vide à l'endroit du dépôt — jamais le
dialogue. La boîte de dialogue existait bien (masquée comme prévu, en
attente d'une action qui l'affiche) : c'est l'enveloppe qui l'entoure qui
n'aurait jamais dû être visible.

Cause : tout exemplaire d'élément de jeu est posé avec le widget générique
"conteneur" par défaut (add_element.py, colonne default_widget) — son
contenu réel (le modèle) est rechargé EN DIRECT à l'intérieur
(_render_element_type_children), mais l'enveloppe "conteneur" elle-même
reste une boîte NORMALE, toujours visible, avec sa propre couleur de fond/
bordure et sa position fixe sur le canevas (contrairement à une
superposition posée directement, qui, elle, démarre masquée). Résultat :
une boîte vide et permanente à l'endroit du dépôt, pendant que le vrai
dialogue (démarré masqué, correctement) reste invisible en dessous/
au-dessus tant qu'aucune action ne le déclenche.

Correctif : quand le modèle ENTIER d'un élément de jeu n'est qu'une seule
superposition (screens/element_types/is_overlay_only.py, nouveau), on
court-circuite entièrement l'enveloppe "conteneur" (render_element_html.py)
et on retire aussi le z-index de son cadre de positionnement
(element_style_filter.py, list_elements.py) — même raison que pour une
superposition posée directement (81c31a9) : sans ça, ce cadre reste un
candidat à piéger le z-index:9999 du dialogue rendu à l'intérieur dès
qu'un autre élément de la scène a un z-index plus grand.

Nouveau test, confirmé en échec sur l'ancien code (même "class=\"box\""
fantôme reproduit) puis au vert avec le correctif. 140 tests au vert au
total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 06:58:44 +02:00
williamandClaude Sonnet 5 78a373a536 Corrige la vraie cause : la boîte de dialogue restait invisible DANS L'ÉDITEUR (masquée par défaut)
Après vérification serveur, le texte était bel et bien rendu dans le HTML
(couleur correcte incluse) — donc pas un souci de contenu ni de couleur.
La vraie cause : le widget "Superposition / boîte de dialogue" démarre
MASQUÉ par défaut à sa création (display:none, voir
default_style_for_widget.py — pour ne pas couvrir tout l'écran dès qu'on
le pose). Ce display:none est écrit tel quel dans le HTML aussi bien en
mode jouable QUE dans l'éditeur, puisque le rendu de ce widget est
partagé par les deux. Résultat : toute la boîte (et donc son contenu,
peu importe le texte ou sa couleur) restait invisible dans le CANEVAS DE
L'ÉDITEUR dès l'instant de sa création — impossible d'y voir/positionner
visuellement ce qu'on pose dedans tant qu'on n'a pas pensé à basculer
manuellement "Visibilité" sur "Visible" (puis à y repenser pour la
remettre sur "Masqué" avant de tester en jeu).

Correctif (render_overlay.py) : dans l'ÉDITEUR uniquement (détecté via le
ctx "_forge_play_mode" déjà posé par list_elements.py pour le mode
jouable), un "display:flex" est ajouté en dernier dans le style —
gagnant sur le "display:none" par défaut (CSS : la dernière déclaration
de la même propriété l'emporte). Le mode JOUABLE, lui, continue de
respecter ce réglage normalement (masqué tant qu'aucune action ne
l'affiche).

Nouveau test, confirmé en échec sur l'ancien code puis au vert avec le
correctif : vérifie explicitement que le style se termine par
"display:flex;" dans l'éditeur et par "display:none;" en mode jouable.
139 tests au vert au total. Jeu de démo régénéré.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 06:30:24 +02:00
williamandClaude Sonnet 5 69bafcb5c5 Corrige le texte invisible dans la boîte de dialogue (régression du style Bulma)
Régression du commit précédent (d87d6ad, classes Bulma sur la
superposition) : la classe ".box" impose elle-même une couleur de texte
SOMBRE (pensée pour un fond blanc). Comme aucun widget ne fige de couleur
de texte à sa création (default_style_for_widget.py), un titre/texte posé
dans la boîte SANS couleur personnalisée héritait de ce gris sombre
imposé par Bulma — invisible sur le fond sombre par défaut de cette boîte
de dialogue. Ni erreur serveur ni régression de test visible : juste un
texte "présent mais invisible", exactement ce qui a été rapporté ("j'ai
mis un texte dedans mais il ne se voit pas").

Correctif : la boîte fixe elle-même une couleur de texte claire par
défaut (color:#e8eaf0) dans son propre style inline — l'élément le plus
proche gagne, donc ça écrase la couleur imposée par la classe Bulma, tout
en restant surchargeable : un titre/texte qui personnalise sa propre
couleur (réglage "Couleur du texte") continue de l'emporter normalement.

Nouveau test de régression, confirmé en échec sur le commit précédent
(même style Bulma, pas encore cette couleur) puis au vert avec le
correctif. 138 tests au vert au total. Jeu de démo régénéré.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 06:18:19 +02:00
williamandClaude Sonnet 5 d87d6addce Boîte de dialogue (superposition) stylisée avec les classes Bulma
Le widget "Superposition / boîte de dialogue" gagne les classes Bulma
"modal is-active" (sur le voile plein écran) et "box" (sur la boîte
centrée), en PLUS du style inline existant — jamais à sa place : tout le
positionnement/masquage critique (position:fixed, z-index, display) reste
en inline, qui gagne toujours sur une règle de classe. Si Bulma (chargé
depuis un CDN, voir play.html) ne se charge pas (hors-ligne), la boîte de
dialogue continue de fonctionner exactement pareil — ces classes n'ajoutent
qu'un habillage visuel (ombre, base de police/espacement Bulma) qui se
dégrade sans rien casser.

Pas de ".modal-background" séparé (convention Bulma habituelle) : le
voile semi-transparent est déjà posé en inline sur la même balise
(background:rgba(...)), un second calque tout aussi transparent
par-dessus n'aurait ajouté qu'un assombrissement redondant.

137 tests toujours au vert (changement purement visuel, aucune assertion
cassée) ; jeu de démo régénéré et vérifié (classes bien présentes dans le
rendu).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 06:10:17 +02:00
williamandClaude Sonnet 5 7c0f1ed427 Corrige la régression du correctif overlay : /elements/<id>/geometry plantait (500)
Le correctif précédent (81c31a9) retirait TOUTE position/taille du cadre
.canvasElement/.playElement d'une "superposition" pour ne plus piéger son
propre z-index:9999 dans un contexte d'empilement local — mais ce même
cadre sert aussi, dans l'ÉDITEUR, de prise pour le glisser-déposer/
redimensionnement de ce widget (screen_edit.html). Sans
position:absolute + left/top/width/height, ce cadre s'effondre à 0×0 (son
contenu réel est en position:fixed, hors flux) : le calcul de geometrie
divise alors par une dimension nulle, produit NaN, et
JSON.stringify(NaN) envoie littéralement "null" au serveur — qui plantait
sur float(None) dans routes/elements/element_geometry.py (500, reproduit
par l'utilisateur en posant un template lié à un objet et en essayant de
repositionner l'overlay dans l'éditeur).

Correctif plus ciblé : le cadre garde position/left/top/width/height comme
n'importe quel widget (l'éditeur redevient fonctionnel) — SEUL le z-index
est omis pour ce widget précis. position:absolute SANS z-index explicite
(donc z-index:auto) ne crée PAS de nouveau contexte d'empilement CSS :
le z-index:9999 posé plus profond (render_overlay.py) continue donc de se
comparer directement aux autres éléments de l'écran, et gagne toujours —
le bug de superposition visuelle (81c31a9) reste corrigé, sans regression
sur l'éditeur cette fois.

test_confort.py mis à jour (position:absolute attendu, plus d'assertion
"position:static") + nouveau test qui appelle directement la route
/geometry sur une superposition pour confirmer qu'elle répond 200.
137 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 06:07:08 +02:00
williamandClaude Sonnet 5 5c069ae1fe Corrige LE vrai bug : un nom de champ mot-réservé SQL (ex. "order") faisait disparaître l'objet entier
Reproduit à l'identique le cas signalé (objet "dialog" avec les champs
order/spiker/text/level_id/parcour_id) : le champ "order" est un mot
réservé SQL — "CREATE TABLE dialog (order INTEGER, ...)" plante avec
"OperationalError: near \"order\": syntax error". Comme ce crash survient
APRÈS l'INSERT de la ligne _definitions mais AVANT le commit(), rien
n'était jamais persisté : l'objet ENTIER disparaissait, malgré des champs
parfaitement remplis — d'où "j'ai tout rempli comme il faut et aucun objet
n'est créé". Mon précédent correctif (champ "Relation" sans cible) était
réel mais ne couvrait pas ce cas précis.

Cause de fond : chaque nom de colonne (dérivé du nom de champ tapé par
l'utilisateur, via slugify) était interpolé TEL QUEL dans du SQL brut
(CREATE TABLE, INSERT, UPDATE, ALTER TABLE ADD/DROP/RENAME COLUMN) sans
jamais être encadré de guillemets — n'importe quel nom de champ qui soit
aussi un mot réservé SQLite (order, group, index, select, where, table,
key, default, check, references, unique...) déclenchait exactement le
même crash-et-perte-de-transaction, dans n'importe laquelle de ces
opérations.

Correctif général (pas un simple contournement pour "order") :
db/quote_ident.py encadre tout identifiant de colonne de guillemets
doubles (forme standard SQL, supportée par SQLite) — appliqué partout où
un nom de colonne utilisateur est interpolé dans du SQL brut :
create_definition, add_field_to_definition, delete_field, update_field
(RENAME COLUMN), insert_row, update_row, update_row_field,
rows_referencing. Les noms de TABLE n'ont pas besoin de cette protection
(table_name_for.py les préfixe toujours "obj_", donc jamais un mot réservé
à eux seuls).

Trois nouveaux tests (tests/test_reserved_sql_keyword_field_names.py) :
création avec un champ "order" + insertion/lecture/mise à jour d'une
ligne, renommage d'un champ vers/depuis un mot réservé ("group"), ajout
d'un champ "select" à un objet existant — les trois confirmés en échec
sur l'ancien code (même erreur reproduite) puis au vert avec le
correctif. 136 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:27:09 +02:00
williamandClaude Sonnet 5 c14f7e9ba5 Corrige le vrai bug : un champ "Relation" sans objet cible plantait toute la création
L'utilisateur avait raison de contester mon précédent correctif : le
problème n'était pas l'absence de champs. Reproduit précisément : créer un
objet avec un champ de type "Relation vers un autre objet" SANS avoir de
cible valide sélectionnée (ex. le tout premier objet créé dans un jeu — le
sélecteur "Objet lié" est alors vide, faute d'un autre objet à pointer)
plantait toute la requête avec une ValueError ("invalid literal for int()
with base 10: ''") dans create_definition() / add_field_to_definition()
(int(relation_definition_id) sans filet). Comme le crash survient APRÈS
l'INSERT de la ligne _definitions mais AVANT le commit(), rien n'était
jamais persisté (transaction perdue à la fermeture de la connexion) :
l'objet entier disparaissait, pas seulement son champ "Relation" — d'où
"le panneau recharge la page sans créer d'objet" alors que des champs
avaient bien été renseignés.

Correctif (routes, pas la couche db) : un champ "Relation" dont la cible
n'est ni choisie ni un id valide est maintenant simplement IGNORÉ (comme
une ligne sans nom, déjà le cas), dans les deux endroits qui construisent
ce payload :
- routes/objects/parse_field_rows.py (panneau "+ Nouvel objet")
- routes/objects/object_field_add.py (panneau "+ Ajouter un champ" d'un
  objet déjà créé — même risque de crash dans add_field_to_definition)
object_field_edit.py/update_field.py avaient déjà la bonne garde
("if relation_definition_id" avant le int()) — rien à y changer.

Deux nouveaux tests, confirmés en échec sur l'ancien code (git stash,
même ValueError reproduite) puis au vert avec le correctif. 133 tests au
vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:10:54 +02:00
williamandClaude Sonnet 5 6cf4fdbe72 Corrige la création d'objet sans champ : le panneau rechargeait la page sans rien créer
Bug rapporté : le panneau "+ Nouvel objet" du tableau de bord "recharge la
page sans créer d'objet". Cause : object_new exigeait "name AND fields"
pour créer quoi que ce soit — or le formulaire du panneau permet de taper
le nom et de cliquer directement "Créer l'objet" SANS avoir cliqué au
préalable "+ Ajouter un champ" (les champs se posent typiquement APRÈS,
depuis le panneau "Modifier un objet", workflow déjà supporté). Sans champ
soumis, la condition échouait, la route redirigeait silencieusement vers
le tableau de bord SANS créer l'objet ET sans le moindre message d'erreur
— vécu comme "un rechargement qui ne fait rien".

create_definition(fields=[]) fonctionne déjà très bien (crée juste une
table avec id/created_at, sans colonne "métier") : retiré l'exigence d'au
moins un champ, ne reste que "name" non vide.

Nouveau test (tests/test_object_new_without_fields.py), confirmé en échec
sur l'ancien code (git stash) puis au vert avec le correctif.
131 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:03:29 +02:00
williamandClaude Sonnet 5 7dd7551bc5 Corrige le texte illisible dans les boîtes de dialogue du jeu de démo
Pas un bug moteur cette fois : le moteur ne fige JAMAIS de couleur de
texte par défaut à la création d'un élément (voir
default_style_for_widget.py) — un titre/texte fraîchement posé retombe
donc sur le texte SOMBRE par défaut de Bulma, pensé pour un fond clair.
Mon script de démo ne posait jamais explicitement de couleur de texte sur
les titres/paragraphes à l'intérieur des boîtes de dialogue (fond sombre
volontaire) : le texte y était donc quasi invisible, ce qui donnait
l'impression trompeuse que "la boîte de dialogue est assombrie" — alors
que seul son texte, invisible par défaut sur fond sombre, l'était (la
boîte elle-même a bien sa couleur opaque demandée, #20263a/#1f3a24/
#2a2440, sans aucun voile supplémentaire dessus).

Ajout de la couleur de texte manquante (#f5f6fa) sur chaque titre/texte
posé dans une boîte de dialogue. Valeur volontairement différente de la
suggestion par défaut du panneau (#e8eaf0) : save_element_controls.py
ignore délibérément un réglage renvoyé identique à sa valeur par défaut
tant qu'il n'a jamais été personnalisé (même logique anti-figeage que
default_style_for_widget.py) — envoyer #e8eaf0 tel quel n'aurait donc eu
AUCUN effet, silencieusement (piège rencontré et documenté dans le script).

Jeu de démo régénéré (supprimé puis reconstruit) avec le correctif.
129 tests toujours au vert (script indépendant, aucun changement moteur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:52:17 +02:00
williamandClaude Sonnet 5 81c31a9c49 Corrige la boîte de dialogue (superposition) : un élément posé après elle s'affichait par-dessus
Bug visible sur le jeu de démo : le bouton "Clique-moi !" restait visible
ET cliquable AU-DESSUS du dialogue de bienvenue censé couvrir tout l'écran,
et la boîte de dialogue elle-même s'étirait bord à bord au lieu de rester
une boîte centrée lisible.

Cause (stacking context CSS) : le widget "superposition" ignore x/y/
width/height et pose lui-même position:fixed; inset:0; z-index:9999 sur
SA PROPRE balise (render_overlay.py) — mais le cadre .playElement/
.canvasElement qui l'entoure, PARTAGÉ PAR TOUS LES WIDGETS (filters/
element_style_filter.py), continuait quand même à poser
"position:absolute; z-index:<sa place dans le canevas>" (souvent petit,
ex. 1). Un élément positionné avec un z-index explicite crée un NOUVEAU
contexte d'empilement CSS : le 9999 posé plus profond ne se comparait
alors plus qu'AU SEIN de ce contexte, et perdait face au z-index (plus
grand) d'un élément ajouté APRÈS l'overlay sur le canevas — qui
s'affichait donc par-dessus le dialogue.

Correctif : _element_style ne pose plus aucune position/z-index pour ce
widget (position:static — sa place dans le flux est de toute façon
invisible, son contenu réel étant en position:fixed). Plus de contexte
d'empilement local créé à ce niveau : le z-index:9999 se compare
directement à tous les autres éléments de l'écran, et gagne toujours.

Profité de l'occasion pour donner à la boîte une largeur par défaut plus
raisonnable (render_overlay.py : max-width:min(560px, 90%) au lieu de
90% seul) — sur un écran de jeu large, "90%" donnait une boîte étirée
bord à bord peu lisible comme dialogue ; 560px reste confortable, et 90%
prend toujours le relais sur un écran étroit (mobile/portrait).

Nouveau test de régression (test_overlay_wrapper_does_not_trap_its_own_z_index) :
confirmé en échec sur l'ancien code (git stash), au vert avec le
correctif. 129 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:43:29 +02:00
williamandClaude Sonnet 5 28d8cd8cd0 Ajoute un script de démo pour tester dialogues/surbrillance/message différé
Les trois mécanismes de guidage du joueur discutés (boîte de dialogue en
popup, réaction à un clic, mise en évidence d'un élément à cliquer)
existent DÉJÀ dans le moteur (voir README, section "Survol, séquences
temporisées, surbrillance, overlay, verrouillage") : widget "Superposition
/ boîte de dialogue", action "Modifier un élément → Surbrillance", et
déclencheur "affichage" combiné à l'action "Attendre" pour un message qui
arrive tout seul après quelques secondes. Rien à coder côté moteur.

scripts/build_demo_dialogues.py construit, via les VRAIES routes Flask
(mêmes routes qu'utilise le navigateur), un jeu de démo persistant
("Demo Dialogues Surbrillance") avec deux écrans :
- "Tutoriel" : dialogue de bienvenue à l'ouverture de l'écran -> son
  bouton OK ferme le dialogue et met un autre bouton en surbrillance ->
  cliquer ce bouton l'éteint, le désactive (verrouillage anti-reclic) et
  ouvre un dialogue "Bravo" en réaction, fermable à son tour.
- "Message différé" : un dialogue "mentor" apparaît tout seul 3 secondes
  après l'affichage de l'écran (affichage -> attendre -> visibilité),
  sans aucune action du joueur.

Vérifié via /runtime-payload (nombre de nœuds/arêtes attendu sur les deux
écrans) et /play (overlay + surbrillance + attente bien exposés côté
rendu). 128 tests toujours au vert (script indépendant, aucun changement
moteur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:59:21 +02:00
williamandClaude Sonnet 5 169b720620 Corrige la sauvegarde des données d'un objet : collision d'id de formulaire entre objets
Bug rapporté : modifier une donnée dans l'onglet "Données" d'un objet
redirigeait vers le panneau d'un AUTRE objet (le premier de la liste) et
n'enregistrait rien sur le bon objet.

Cause : chaque objet a sa propre table SQLite (db/rows/insert_row.py), donc
les ids de ses lignes repartent de 1 - deux objets ont chacun une ligne
#1, #2, etc. Or game_dashboard.html générait les formulaires d'édition/
suppression d'une ligne avec un id DOM basé seulement sur r.id
("dataEditForm{{r.id}}"), jamais sur l'objet auquel elle appartient. Les
<input form="dataEditForm1"> de DEUX objets différents pointaient donc
vers le même id de formulaire dupliqué dans le document - et un id HTML
dupliqué se résout vers le PREMIER élément trouvé (le premier objet listé),
pas celui réellement affiché sous les yeux de l'utilisateur.

Correctif : les ids de formulaires ("dataEditForm"/"dataDeleteForm") et les
attributs form="..." des champs sont maintenant scopés par objet ET par
ligne ("dataEditForm{{d.id}}-{{r.id}}"), comme c'était déjà le cas pour les
panneaux (objectEditPanel, addEntryPanel...) via data-definition-id.

Nouveau test (tests/test_data_form_id_collision.py) : reproduit le
scénario exact (deux objets ayant chacun une ligne #1) et vérifie que les
ids de formulaire sont bien distincts et que la modification du second
objet ne touche pas le premier - confirmé en échec sur l'ancien code
(git stash) puis au vert avec le correctif. 128 tests au vert au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:31:58 +02:00
williamandClaude Sonnet 5 82a14fcb2b Panneau valeur variable Objet/Tableau : bouton +Ajouter visible et sauvegarde auto
Deux retours utilisateur sur le panneau "Configurer la valeur" ajouté au
commit précédent :
- le bouton "+ Ajouter" (style is-outlined) se voyait mal dans le panneau
  sombre -> repassé en bouton "primary" pleine largeur, et déplacé sous la
  liste des lignes (là où on l'attend pour ajouter une ligne de plus,
  plutôt qu'au-dessus, hors du flux de lecture) ;
- le bouton "OK - utiliser cette valeur" était un aller-retour inutile :
  supprimé. Chaque changement (texte tapé, clé, type, ajout ou retrait
  d'une ligne) réécrit désormais tout de suite le JSON dans le champ
  cible via saveVarValuePanel(), écoutée sur les événements input/change
  du conteneur des lignes - exactement comme si on tapait directement
  dans le champ, sans étape de validation séparée.

Aucun changement Python (127 tests toujours au vert) ; vérifié la syntaxe
du <script> rendu de l'onglet Variables via node --check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:56:03 +02:00
williamandClaude Sonnet 5 1b24df9546 Éditeur à champs pour les variables Objet/Tableau (plus de JSON à taper)
L'édition en JSON brut (textarea) n'était pas adaptée à un public non
développeur. Remplacée par un panneau "Configurer la valeur" (déplaçable/
redimensionnable/plein écran, comme les autres) avec une ligne par clé
(Objet) ou par élément (Tableau) : un champ Clé (Objet seulement), un
type (Texte/Nombre/Oui-Non) et une valeur en texte libre — le JSON est
reconstruit automatiquement au clic sur "OK", jamais tapé à la main.

Un seul panneau partagé (#varValuePanel) pour toutes les variables : à
l'ouverture (openVarValuePanel(targetInputId, mode)), reconstruit une
ligne par entrée déjà présente dans le JSON actuel de l'<input> ciblé —
un objet/tableau imbriqué en valeur est préservé tel quel (JSON.stringify
en texte opaque) plutôt que perdu, même si cette valeur précise n'est pas
éditable via une ligne (portée volontairement limitée aux valeurs
simples : suffisant pour une configuration ou une liste, sans la
complexité d'un éditeur JSON récursif).

- Barre "+ Créer une variable" : le champ de valeur devient en lecture
  seule pour Objet/Tableau, avec un bouton "🔧 Configurer" à côté.
- Ligne d'une variable existante : la cellule Valeur montre un aperçu en
  lecture seule + le même bouton "🔧 Configurer" (au lieu du textarea
  JSON du commit précédent).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:41:37 +02:00
williamandClaude Sonnet 5 aaf9954446 Variables globales Objet/Tableau, lisibles via un chemin dans les conditions et filtres
Deux nouveaux types de variable globale (db/constants.py) : "objet" et
"tableau" — valeur stockée en JSON (colonne TEXT existante), avec
validation à la création/modification (db/global_vars/
coerce_structured_value.py) : un JSON invalide retombe sur un défaut sûr
("{}"/"[]") plutôt que de corrompre silencieusement la variable pour
toutes ses lectures suivantes. apply_variable_action.py n'a besoin
d'aucun changement — "definir_texte" écrit déjà n'importe quelle chaîne
telle quelle.

Nouvelle résolution de chemin, partagée (screens/rendering/
filter_repeater_rows.py::_resolve_variable_path) : navigue dans la
valeur JSON d'une variable selon un chemin ".champ"/"[index]" chaînable
(ex. ".arme.degats", "[0].valeur") — ne lève jamais, renvoie None si le
JSON est invalide ou qu'un segment du chemin ne correspond à rien.
Branchée à deux endroits, qui lisaient déjà une variable globale :

- La valeur de comparaison {{$nom_variable}} (filtres de Répéteur ET
  Condition de visibilité, qui partagent le même
  _resolve_filter_value()) accepte maintenant un chemin optionnel :
  {{$perso.nom}}, {{$scores[0]}}. Le sélecteur "Variable globale" du
  panneau de propriétés (screen_edit.html, .filterValueVariable) gagne un
  champ "Chemin optionnel" à côté du choix de variable — même regex
  étendue côté JS (_VAR_REF_RE) que côté Python (_VAR_REF_PATTERN), pour
  que la valeur round-trip correctement à la réouverture du panneau.

- La variable VÉRIFIÉE par une Condition de visibilité en mode "variable"
  (choisie via un <select>, pas la syntaxe {{$...}}) gagne son propre
  nouveau contrôle "Chemin dans la variable" (visibility_condition_
  controls.py) — nécessaire pour comparer un champ d'un Objet ou un
  élément d'un Tableau, pas seulement la variable entière.

game_dashboard.html (onglet Variables) : la valeur par défaut de la barre
de création devient un <textarea> (fonctionne aussi bien pour un JSON
multi-ligne qu'un scalaire court), et la cellule "Valeur" du tableau des
variables existantes devient un <textarea> quand le type est objet/
tableau.

4 nouveaux tests (tests/test_variable_object_array.py) : validation JSON
à la création, filtre de Répéteur avec chemin chaîné, condition de
visibilité avec accès par index de tableau, chemin invalide/absent sans
plantage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:28:23 +02:00
williamandClaude Sonnet 5 b21f56e364 Filtre de colonnes + panneau de détail d'une entrée (onglet Données)
Un objet avec beaucoup de champs rendait le tableau "Données
enregistrées" illisible (toutes les colonnes serrées sur une ligne,
valeurs tronquées) — deux ajouts pour compenser :

1) "🔧 Colonnes" : menu déroulant (une case à cocher par champ, toutes
cochées par défaut) au-dessus du tableau — décocher un champ masque sa
colonne (th + td, via [data-col]) sans reconstruire le tableau. Un seul
menu ouvert à la fois, fermé au clic ailleurs sur la page.

2) 👁️ par ligne : ouvre un panneau de détail (déplaçable/redimensionnable/
plein écran par défaut, comme les autres) listant TOUS les champs de
cette entrée, un par ligne, valeur complète non tronquée — reprend le
style .rowDetailField/.rowDetailLabel/.rowDetailValue de l'ancienne
modale Bulma de data_list.html (retirée, mais ce CSS ne dépendait pas du
template et a été gardé). Un seul panneau PAR OBJET (pas par ligne) :
son contenu est reconstruit à l'ouverture en lisant en direct la ligne
du tableau déjà affiché (via [data-row-id] sur chaque <tr>) — reflète
donc aussi une modification tout juste saisie mais pas encore
"Enregistrer"ée, sans aller-retour serveur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:17:10 +02:00
williamandClaude Sonnet 5 943bf265de Panneaux flottants en plein écran par défaut, avec bouton pour en sortir
Chaque panneau (Nouvel objet, Modifier un objet, Ajouter un champ,
Ajouter une entrée) s'ouvre désormais en plein écran par défaut (marge
de 12px) plutôt qu'en petite fenêtre centrée — plus confortable dès
qu'il y a plusieurs champs/entrées à voir en même temps. Un bouton ⛶
dans l'en-tête bascule vers/depuis la taille et la position précédentes
(mémorisées le temps de la session, pas persistées), pour qui préfère
un panneau plus petit à côté du reste du tableau de bord.

makeFloatPanelFullscreenable(panel, toggleBtn) — générique, posée sur
panel._fsToggle — appelée par les 4 familles de panneaux existantes.
makeFloatPanelDraggable()/makeFloatPanelResizable() sortent d'abord
proprement du plein écran si l'utilisateur interagit manuellement
(glisser l'en-tête ou tirer le coin) : sans ça, bottom/right encore
actifs en plein écran auraient repris la main sur la position/taille
qu'on vient de poser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:06:58 +02:00
williamandClaude Sonnet 5 03db681022 Panneaux dédiés pour "Ajouter un champ"/"Ajouter une entrée"
La barre en ligne (tous les champs alignés horizontalement) devenait
illisible au-delà d'une poignée de champs — un objet à 10 champs
donnait une ligne de saisie qui débordait largement de l'écran.

Remplacée par un simple bouton "+ Ajouter" qui ouvre un panneau dédié
(déplaçable et redimensionnable, comme les autres) avec un formulaire
VERTICAL — un champ par ligne, étiquette au-dessus, comme un formulaire
normal — plutôt qu'entassé sur une seule ligne. Un panneau "Ajouter un
champ" et un panneau "Ajouter une entrée" par objet, tous deux cachés
par défaut.

Réutilise makeFloatPanelDraggable()/makeFloatPanelResizable() (déjà
génériques, voir commit précédent) — aucun nouveau mécanisme de
glisser-déposer à écrire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:58:16 +02:00
williamandClaude Sonnet 5 0e390a679b Ajoute l'onglet "Données" au panneau objet, retire object_view.html
Suite de la demande : le panneau "Modifier un objet" (crayon ✏️, onglet
Objets du tableau de bord) a maintenant 2 sous-onglets — "Champs" (déjà
en place) et "Données", qui reprend data_list.html + data_form.html
(retirés) : ajouter une entrée (une ligne compacte avec le bon type de
champ par colonne — texte/nombre/case à cocher/date/relation, comme
l'ancien formulaire), modifier/supprimer une entrée existante (tableau
dense, cellules éditables en ligne, même principe que "Champs
existants").

Sous-onglets scopés au panneau de LEUR objet (switchObjectPanelTab(),
classes .objectPanelTabs/.objectPanelTabPanel distinctes de .builderTabs/
.builderTabPanel) — plusieurs objets ont chacun leurs propres
sous-onglets indépendants sur la même page, sans jamais interférer avec
les onglets du tableau de bord lui-même.

routes/games/game_dashboard.py fournit maintenant, par objet : ses lignes
(rows_by_definition), les libellés lisibles de ses champs relation
(relation_labels_by_definition, pour l'affichage) et leurs options
(relation_options_by_definition, pour les <select>), ainsi que
referenced_by_definition (avertissement permanent si un autre objet a
une relation vers celui-ci — repris de l'ancien object_view.py, affiché
maintenant en continu plutôt qu'après une tentative de suppression
échouée).

data_form.py (partagé par data_new/data_edit), data_delete.py et
object_delete.py redirigent maintenant vers le tableau de bord
(?edit=<id>&subtab=data, +?blocked_row=<id> si la suppression d'une
entrée est bloquée par une relation) au lieu de object_view/object_edit.

Piège évité : de nombreux tests déduisent l'id d'un objet fraîchement
créé du DERNIER SEGMENT du chemin dans le header Location d'une
redirection (.../objects/<id>) — rediriger object_new directement vers
le tableau de bord (chemin sans id) cassait donc 33 tests d'un coup.
Fix : object_view.py reste en place, mais seulement comme redirecteur
(plus de page rendue) — object_new redirige toujours vers lui (chemin
qui se termine par l'id, donc les tests continuent de fonctionner), qui
redirige à son tour vers le tableau de bord.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:13:05 +02:00
williamandClaude Sonnet 5 b8dfdcb205 Corrige la nav pjax, panneau déplaçable/redimensionnable pour éditer un objet
1) Bug pjax trouvé : swapDocument() cherchait "header.topbar", qui n'a
jamais existé (c'est un <nav>, pas un <header>) — la barre de navigation
du jeu (ajoutée après pjax.js) n'était donc jamais mise à jour pendant
une navigation pjax : absente en arrivant sur un jeu sans Ctrl+F5, et
inversement laissée en place en revenant sur l'accueil où elle n'a rien
à faire. Fix : tout ce qui est hors <main> mais doit changer d'une page
à l'autre (topbar + barre du jeu) est regroupé dans un nouveau conteneur
stable #pageChrome, que pjax.js remplace en bloc — plus fiable qu'un
sélecteur qui ne correspondait à rien. CSS (flex:0 0 auto des layouts
plein-écran) mis à jour en conséquence.

2) Renommages demandés : onglet/panneau "Écrans du jeu" -> "Écrants",
"Éléments de jeu" -> "Templates" (tab, titre de panneau, bouton
"+ Créer un template", état vide, infobulle, confirmation de
suppression).

3) Le crayon ✏️ sur une ligne d'objet ouvre désormais un panneau
déplaçable ET redimensionnable (nouveau coin de redimensionnement
générique, .floatPanelResizeHandle) au lieu de naviguer vers
object_edit.html (retirée) — un panneau par objet, pré-rendu et
caché par défaut. Reprend telles quelles les fonctionnalités de
l'ancienne page : renommer l'objet, ajouter un champ (ligne compacte),
modifier/supprimer un champ existant (tableau dense déjà repris pour
"Nouvel objet"), supprimer l'objet. Les routes de champs (object_field_
add/edit/delete) et object_edit lui-même redirigent maintenant vers le
tableau de bord avec ?edit=<id>, pour rouvrir automatiquement le bon
panneau après l'action plutôt que de le fermer silencieusement.

makeFloatPanelDraggable()/makeFloatPanelResizable() généralisées pour
être partagées entre "Nouvel objet" et les panneaux d'édition, plutôt
que du code dupliqué par panneau.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:56:49 +02:00
williamandClaude Sonnet 5 e1216e99d5 Rend le panneau "Nouvel objet" compact — lignes de tableau, pas des cartes
Chaque champ prenait une grosse carte (~250px de haut : libellés Bulma
pleine taille, "Retirer ce champ" en texte) — inutilisable pour un objet
à 10+ champs, ce qui est pourtant l'usage visé par ce panneau.

Remplacé par une vraie ligne de tableau dense, sur le même principe que
"Champs existants" dans object_edit.html (déjà compact et sobre dans le
reste de l'outil) : une ligne = un champ, colonnes Nom/Type/Objet
lié/Mini/Maxi/Obligatoire, action "Retirer" réduite à une icône. Les
colonnes conditionnelles (Objet lié pour une relation, Mini/Maxi pour un
nombre) restent TOUJOURS présentes — sans quoi les colonnes de lignes
différentes ne s'aligneraient plus — seul leur contenu bascule entre le
vrai champ de saisie et un espace réservé "—", au lieu de masquer toute
la cellule comme avant.

object_form.js adapté en conséquence : bounds désormais 2 cibles
séparées (mini/maxi, chacune dans sa propre cellule) au lieu d'une seule
enveloppe commune, et chaque bascule s'accompagne de celle de son
espace réservé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 11:04:13 +02:00
williamandClaude Sonnet 5 d875557254 Fusionne les 4 pages de création dans le dashboard, retire le titre/BDD
Suite de la demande précédente : les 4 pages autrefois listées dans la
barre de navigation n'existent plus en tant que pages séparées — tout
vit désormais dans les onglets du tableau de bord (commit précédent) ou,
pour la création d'un objet, dans un panneau déplaçable.

- screens_list.html + sa route (screens_list) : supprimés (l'onglet
  "Écrans du jeu" du dashboard couvre déjà tout : créer, réordonner,
  éditer, supprimer). screen_new/screen_move/screen_delete redirigent
  maintenant vers le dashboard (tab=screens) au lieu de cette page.
- element_types.html : supprimé, mais la route element_types est
  conservée (GET redirige vers le dashboard, POST — utilisé par la barre
  de création repliable de l'onglet "Éléments de jeu" — continue de
  fonctionner). element_type_edit/element_type_delete redirigent aussi
  vers le dashboard.
- game_variables.html + sa route (game_variables) : supprimés (l'onglet
  "Variables" du dashboard couvre déjà tout). create_global_var/
  global_var_edit/global_var_delete redirigent vers le dashboard
  (tab=variables) au lieu de cette page.
- object_form.html : supprimé. La route object_new (POST) est conservée
  pour traiter la soumission du panneau — voir plus bas — mais ne rend
  plus de page pour un GET (redirige vers le dashboard).

Nouveau panneau déplaçable "Nouvel objet" sur le dashboard (bouton
"+ Nouvel objet" de l'onglet Objets) : réutilise .floatPanel/
.floatPanelHeader/.floatPanelBody (déjà utilisées dans l'éditeur d'écran)
avec une nouvelle variante centrée (.floatPanel--center) et son propre
glisser-déposer minimal (pas de position persistée, contrairement aux
panneaux de l'éditeur d'écran — inutile pour un panneau ouvert
ponctuellement). Contenu et script (object_form.js) repris tels quels de
l'ancienne page.

base.html : la barre de navigation du jeu n'a donc plus que 2 liens —
"📊 Tableau de bord" (nouveau) et "▶️ Jouer" (toujours en dernier).

game_dashboard.html : titre du jeu et chemin de la base de données
retirés (redondants avec le nom déjà visible dans l'onglet du
navigateur/la barre de nav).

Les liens "crée-en un"/"gérer les variables" dans l'éditeur d'écran
(screen_edit.html) pointent maintenant vers le dashboard avec le bon
onglet (?tab=...), lu et appliqué au chargement de la page
(switchDashTab() côté JS).

2 tests (test_screens_and_elements.py) mis à jour : ils vérifiaient le
contenu des pages supprimées (element-types, screens) — adaptés pour
vérifier la même chose sur le dashboard, qui porte maintenant cette
information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:54:19 +02:00
williamandClaude Sonnet 5 65450a5719 Refait le dashboard en fenêtre à onglets avec de vrais tableaux
Le précédent dashboard (grille de cartes compactes) ne correspondait pas
à ce que l'utilisateur voulait : une seule fenêtre avec de vrais tableaux
de données (denses, colonnes nettes), un bouton "Créer" par catégorie
dans l'en-tête, et une navigation horizontale pour passer d'une catégorie
à l'autre.

Réutilise telles quelles .builderTabs/.builderTabBtn/.builderTabPanel
(déjà utilisées pour "Écran / Logique / Timeline" dans l'éditeur d'écran)
plutôt que d'inventer un 2e système d'onglets — même sensation partout
dans l'outil. Un onglet par catégorie (Écrans/Objets/Éléments de
jeu/Variables), chacun avec :
  - un bouton "+ Créer" dans l'en-tête qui révèle une barre de création
    compacte (repliée par défaut) — sauf pour les Objets, dont la
    création (plusieurs champs typés) reste sur sa propre page dédiée,
    trop complexe pour tenir dans une barre ;
  - le VRAI tableau de gestion de cette catégorie (colonnes, actions),
    repris tel quel de screens_list.html/element_types.html/
    game_variables.html plutôt que réinventé en version appauvrie.

La page défile désormais normalement (retrait de body.objectEditBody/
content-objectEdit, pensés pour une hauteur figée avec défilement
interne) — une liste peut être longue, pas besoin d'un défilement séparé
par panneau ici.

routes/games/game_dashboard.py fournit en plus variable_types
(db.GLOBAL_VARIABLE_TYPES) pour la barre de création de variable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:15:37 +02:00
williamandClaude Sonnet 5 8f45169209 Retire le fil d'Ariane, centre/réordonne la barre de nav, refait le dashboard
Trois demandes distinctes de l'utilisateur, regroupées car elles touchent
toutes à la navigation d'un jeu :

1. Fil d'Ariane retiré (devenu redondant avec la barre de navigation du
   jeu ajoutée au commit précédent) : bloc breadcrumb_wrap retiré de
   base.html, et son override ({% block breadcrumb %}) retiré des 9
   templates qui le définissaient encore. .breadcrumbBar (CSS) retiré,
   y compris des règles flex:0 0 auto de body.objectEditBody/builderBody.

2. Liens de .gameNavBar centrés (justify-content:center).

3. "Jouer" déplacé en dernier lien (c'est une action à part — ouvre
   l'aperçu jouable dans un nouvel onglet — pas un éditeur de plus comme
   les 4 autres).

4. game_dashboard.html devient un vrai tableau de bord : une grille de
   cartes (Écrans/Objets/Éléments de jeu/Variables), chacune listant les
   entrées existantes avec un accès direct (clic = éditeur concerné) et
   un lien "Gérer" vers la page dédiée pour créer/réorganiser. Remplace
   l'ancien panneau "Créer" (redondant avec la barre de navigation
   persistante) et la simple table "Objets définis". routes/games/
   game_dashboard.py alimente maintenant aussi screen_list, element_types
   (+ usage) et variables, en réutilisant list_screens/
   list_element_types/element_type_usage_count/list_global_variables déjà
   utilisés ailleurs.

Vérifié en rendant toutes les pages concernées via le client de test
Flask : barre de nav présente partout où un `game` est dans le contexte
(absente sur l'accueil), fil d'Ariane absent partout, ordre des liens
avec "Jouer" en dernier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 10:04:09 +02:00
williamandClaude Sonnet 5 de89115534 Ajoute une barre de navigation persistante entre les éditeurs d'un jeu
Jusqu'ici, les liens vers les différents éditeurs d'un jeu (Écrans,
Éléments de jeu, Variables, Jouer, Objet) n'existaient que dans le
panneau "Créer" du tableau de bord (game_dashboard.html) — changer
d'éditeur obligeait à revenir sur cette page à chaque fois.

base.html expose désormais une 2e barre (.gameNavBar), sous la barre
"Forge Engine" et au-dessus du fil d'Ariane, reprenant ces mêmes 5
liens — visible sur TOUTE page qui met un `game` dans le contexte du
template (déjà fait par chaque route pour le fil d'Ariane, donc aucun
changement de route nécessaire), absente sur l'accueil (liste des jeux,
pas de jeu courant). L'onglet correspondant à la section actuelle est
mis en évidence via un simple préfixe sur request.path.

static/style.css : nouvelle barre en ligne (contrairement à .navList/
.navRow, empilés verticalement dans le panneau "Créer" du tableau de
bord, réutilisés tels quels ailleurs). Ajoutée aux règles flex:0 0 auto
de body.objectEditBody/body.builderBody (mise en page plein-écran des
éditeurs) aux côtés de .topbar/.breadcrumbBar, sans quoi elle aurait
cassé la répartition de hauteur figée de ces pages.

Vérifié en rendant plusieurs routes via le client de test Flask : barre
absente sur l'accueil, présente partout ailleurs (tableau de bord, liste
des écrans, éditeur d'écran normal ET d'écran-modèle, éléments de jeu,
variables, nouvel objet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 09:20:05 +02:00
williamandClaude Sonnet 5 ffedb16d8a Inclut les écrans-modèles dans gameData.flows/animations
Cause du "la logique posée sur le modèle ne s'applique pas dans la
scène" (persistant malgré les 2 commits précédents) : full_game_payload()
appelait list_screens(slug) SANS include_templates=True — un
écran-modèle (ex. "Modèle : mail content") n'apparaissait donc jamais
dans gameData.flows ni gameData.animations côté client. findTriggerNode()
cherche pourtant bien un déclencheur dans TOUTES les clés de
gameData.flows — mais si l'écran-modèle n'y a même pas d'entrée, il n'y
a rien à trouver, quelle que soit la justesse de cette recherche.

Fix : les nœuds/fils de logique et les clips d'animation sont désormais
lus pour TOUS les écrans (list_screens(slug, include_templates=True)),
dans une boucle séparée de celle qui construit payload_screens — celle-
ci continue de ne lister que les vrais écrans, pour ne jamais rendre un
écran-modèle comme un <div class="playScreen"> à part entière (il n'est
jamais affiché tel quel, seulement rechargé en direct à l'intérieur d'un
élément qui l'utilise).

Vérifié sur les vraies données du jeu de test : gameData.flows contient
désormais bien l'écran 3 (le modèle "mail content"), avec son
déclencheur "Au survol" et son action "Rendre visible" ; payload_screens
ne contient toujours que l'écran réel (1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:42:20 +02:00
williamandClaude Sonnet 5 e653f95d37 Ouvre les onglets Logique/Animation dans l'éditeur d'un modèle réutilisable
Ces onglets ("Logique de la scène", "Timeline d'animation") étaient
masqués sur l'écran-modèle d'un élément de jeu réutilisable, avec un
commentaire expliquant pourquoi : à l'époque, ses enfants étaient COPIÉS
en base avec un NOUVEL id à chaque exemplaire posé
(instantiate_template_tree) — une logique/animation posée dans le
modèle référencerait donc des ids qui n'existent plus une fois
l'élément utilisé ailleurs.

Ce mécanisme a depuis été retiré (voir le commentaire dans
list_elements.py) : le contenu d'un élément de jeu réutilisable est
désormais TOUJOURS rechargé EN DIRECT depuis son écran-modèle à chaque
affichage, avec les MÊMES ids à chaque exemplaire. Combiné au commit
précédent (findTriggerNode/runFlowFrom/collectAnimationClips côté
play.html, qui exécutent maintenant correctement un déclencheur/une
animation posé dans un modèle, où qu'il soit utilisé), la restriction
de cette page n'avait donc plus lieu d'être — elle bloquait justement la
fonctionnalité que le commit précédent venait de rendre possible.

Les données nécessaires (flow_nodes/flow_edges/elements du modèle,
etc.) étaient déjà calculées sans condition par la route
(routes/screens/screen_edit.py) ; seul le template masquait les deux
onglets et leur contenu derrière {% if not screen.is_template %}.
switchBuilderTab() détecte déjà la présence des panneaux via
HAS_FLOW_PANEL/HAS_ANIM_PANEL (document.getElementById), donc aucun
changement JS n'était nécessaire.

Vérifié en rendant réellement /game/test/screens/3/edit (l'écran-modèle
"mail content") via le client de test Flask : les deux onglets sont
maintenant bien présents, et l'écran normal (id=1) n'est pas affecté.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:41:48 +02:00
williamandClaude Sonnet 5 17c5e93d8d Fait fonctionner logique et animations d'un modèle réutilisable partout où il est posé
Jusqu'ici, la logique (déclencheurs Au clic/Au survol/Fin du survol) et
les animations posées dans l'éditeur de l'écran-MODÈLE d'un élément de
jeu réutilisable (ex. "mail content") sur SES PROPRES enfants ne
s'exécutaient jamais quand cet élément était simplement posé sur une
autre scène : findTriggerNode() cherchait bien le déclencheur dans tous
les écrans (modèles compris) mais runFlowFrom() n'exécutait ensuite le
graphe que dans l'écran RÉELLEMENT affiché — le nœud trouvé n'existait
pas dans ce graphe-là, donc rien ne se déclenchait, silencieusement.
Même limitation pour les animations, dont la timeline ne lisait que les
clips propres à l'écran affiché.

Logique (templates/play.html) :
- findTriggerNode() renvoie désormais { node, screenId } plutôt que
  juste le nœud, pour transmettre l'écran D'ORIGINE du déclencheur (qui
  peut être un écran-modèle).
- runFlowFrom(nodeId, flowScreenId) accepte un 2e paramètre optionnel
  (par défaut l'écran affiché, comportement inchangé pour tout le
  reste) pour exécuter le graphe dans le BON écran.
- bindClicks()/bindHoverTriggers() passent maintenant cet écran
  d'origine à runFlowFrom(). runScreenShowTriggers() (déclencheur "À
  l'affichage de l'écran") reste volontairement inchangé — hors scope,
  ambiguïté sur plusieurs exemplaires d'un même modèle sur un écran.

Animations (screens/payload/full_game_payload.py, templates/play.html) :
- Le payload expose désormais element_types (element_type_id -> id de
  son écran-modèle), via screens.list_element_types() déjà existant.
- collectAnimationClips(screenId) rassemble récursivement les clips de
  l'écran affiché ET de tout écran-modèle utilisé par un de ses
  éléments (garde anti-boucle, dédoublonnage par écran).
- applyAnimationClip() cible désormais TOUS les exemplaires d'un id
  d'élément (querySelectorAll, plus querySelector) : un enfant de
  modèle garde le même id à chaque exemplaire, y compris pour chaque
  ligne d'un Répéteur utilisant ce modèle comme gabarit de ligne.

Limite connue, non corrigée ici (pas la demande) : une action "Modifier
un élément" ciblant un enfant de modèle reste, elle, scopée au premier
exemplaire trouvé dans le DOM (document.querySelector singulier dans
runActionNode/applyElementProperty) — sans impact pour un modèle posé
une seule fois par écran, comme dans le cas d'usage actuel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 07:33:25 +02:00
williamandClaude Sonnet 5 19d3810164 Calcule rendered_html pour CHAQUE élément, pas seulement le premier niveau
Cause racine réelle des 2 précédents correctifs (commits 20adfa4,
f4a7a73, 5e4e226, f016a81) qui n'avaient AUCUN effet visible malgré des
redémarrages en règle : list_elements() ne calculait "rendered_html" que
pour les éléments de PREMIER NIVEAU (parent_id NULL). Le nouveau ciblage
côté JS (play.html) cherche pourtant à repérer, pour un élément imbriqué
comme le Répéteur ou "mail content", s'il porte lui-même un marqueur ou
un {{champ}} non résolu — mais côté serveur, ces éléments n'avaient tout
simplement PAS de rendered_html du tout : `e.rendered_html` valait
`undefined`, donc `hasMarker`/`hasUnresolvedPlaceholder` retombaient
toujours à `false` pour eux, laissant SEUL le conteneur racine de
premier niveau comme candidat — reproduisant exactement le bug d'origine
(tout l'écran régénéré à chaque clic) qu'aucun des correctifs côté JS ne
pouvait donc jamais résoudre, quelle que soit la justesse de leur
logique de filtrage.

Fix : chaque élément (imbriqué ou non) reçoit désormais son propre
rendered_html — un élément imbriqué s'y retrouve deux fois (une fois
dans le rendered_html de son ancêtre de premier niveau, utilisé pour le
rendu HTML initial de la page ; une fois dans le sien propre, utilisé
par le ciblage précis côté JS). Vérifié par simulation directe sur le
payload réel du jeu de test : les cibles calculées sont maintenant
exactement les 3 jauges, le Répéteur et "mail content" — plus jamais le
conteneur racine de l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 06:49:41 +02:00
williamandClaude Sonnet 5 f016a81dc6 Résout {{champ}} en place au lieu de recréer le nœud (zéro flash, façon React)
L'utilisateur a raison de pointer que le vrai souci n'était pas un bug
isolé mais l'APPROCHE elle-même : detruire puis reconstruire un nœud du
DOM à chaque clic (même bien ciblé, comme depuis les 2 derniers commits)
cause toujours un flash visuel, puisque tout état transitoire du
sous-arbre (visibilité posée par "Modifier un élément", focus...) est
perdu et reconstruit à neuf. C'est ce qui donnait l'impression trompeuse
d'un "rechargement" — un comportement JS parfaitement normal quand on
manipule le DOM ainsi, mais évitable : c'est exactement le problème que
la réconciliation ciblée de React (ne patcher que ce qui a changé,
jamais recréer un nœud pour rien) résout côté framework.

applyOpenRowBindings() ne remplace donc plus JAMAIS le nœud de l'élément
ciblé (ex. "mail content") — il patche directement, en place :
  - un nœud TEXTE contenant {{champ}} est coupé en 3 (texte avant, un
    <span data-bind-field="champ">, texte après) LA PREMIÈRE FOIS
    SEULEMENT ; toute ouverture suivante se contente de changer le
    textContent de ce span — plus aucune reconstruction ensuite.
  - un ATTRIBUT contenant {{champ}} (ex. href="{{link_real_url}}") voit
    son gabarit d'origine mémorisé sur data-bind-attr-<nom> au premier
    passage, pour être recalculé et réécrit directement à chaque fois
    sans jamais reconstruire le nœud.

Plus aucun nœud n'étant détruit, la sauvegarde/restauration de l'état
visuel transitoire (ajoutée dans un commit précédent pour compenser
cette destruction) devient inutile et est retirée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 06:24:42 +02:00
williamandClaude Sonnet 5 5e4e226794 Corrige le filtre "élément le plus spécifique" (logique inversée)
Les deux commits précédents (ciblage des éléments imbriqués dans
applyOpenRowBindings() et refreshRuntimeData()) n'avaient AUCUN effet
visible, confirmé par l'utilisateur après redémarrage du serveur — cause
trouvée : leur filtre "ne garder que les éléments les plus spécifiques"
vérifiait l'inverse de ce qu'il fallait.

Un CONTENEUR contient toujours le HTML de ses descendants dans son
propre rendered_html — donc un ancêtre "a le marqueur/placeholder" quasi
systématiquement dès qu'un descendant l'a. Le filtre précédent excluait
un élément candidat si un de ses ANCÊTRES était candidat — ce qui, vu ce
qui précède, ne gardait quasiment jamais que l'ancêtre RACINE de
l'écran, reproduisant exactement le bug d'origine (tout l'écran
régénéré) que ces commits visaient à corriger.

Fix : inversion du sens du filtre — un candidat est désormais exclu si
l'un de ses PROPRES DESCENDANTS est aussi candidat (le descendant sera
déjà régénéré individuellement, inutile de régénérer aussi son
ancêtre). Vérifié par une simulation Node.js reproduisant la structure
réelle de l'écran de test (Répéteur niché sous 2 conteneurs, "mail
content" sous 2 autres) : la nouvelle logique cible bien uniquement le
Répéteur et "mail content", plus jamais le conteneur racine de l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:04:01 +02:00
williamandClaude Sonnet 5 f4a7a7340e Cible aussi les éléments imbriqués dans refreshRuntimeData()
Même défaut que celui corrigé dans applyOpenRowBindings() (commit
précédent), mais dans le second endroit qui régénère l'écran après un
changement de donnée : refreshRuntimeData() ne pouvait régénérer que les
éléments de PREMIER NIVEAU (seuls eux ont un data-el-id sur leur wrapper
.playElement). Un Répéteur niché dans un conteneur — comme celui de cet
écran — n'est jamais du premier niveau : c'est donc son ANCÊTRE de
premier niveau qui portait le marqueur "repeaterItem" à l'intérieur et
se faisait régénérer en entier à sa place, potentiellement l'écran
complet (jauges, onglets compris) si l'écran n'a qu'un seul gros
conteneur racine. C'était la cause réelle du "rechargement" toujours
visible après le précédent correctif : celui-ci ne portait que sur
applyOpenRowBindings(), pas sur cette 2e régénération déclenchée par
"Modifier une donnée"/"Modifier une variable".

Fix : même principe que le commit précédent — cible chaque élément
marqué (repeaterItem/jaugeBar/visibilityGated) directement via son
data-element-id, à n'importe quel niveau d'imbrication, en ne gardant
que les plus "hauts" parmi les éléments marqués pour ne jamais régénérer
un même nœud deux fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:51:57 +02:00
williamandClaude Sonnet 5 20adfa4169 Ne régénère plus que l'élément concerné par "Ouvrir la ligne cliquée"
Cause du "rechargement" perçu par l'utilisateur (et du flash à vide sur
la ligne de Répéteur juste cliquée, visible sur une vidéo de repro) :
applyOpenRowBindings() ne pouvait cibler que les éléments de PREMIER
NIVEAU de l'écran (seuls eux ont un wrapper .playElement dans le DOM).
Sur cet écran, "mail content" est niché à 2 conteneurs de profondeur, et
le SEUL élément de premier niveau est le conteneur racine de tout
l'écran — donc chaque clic sur une ligne de Répéteur régénérait
littéralement tout l'écran (jauges, onglets, Répéteur compris) pour ne
mettre à jour qu'un seul panneau de détail, avec un flash à vide pendant
la reconstruction.

Fix : applyOpenRowBindings() cible maintenant directement, à n'importe
quel niveau d'imbrication, le(s) élément(s) qui portent réellement un
{{champ}} non résolu (repéré via document.querySelector
('[data-element-id=...]'), disponible sur CHAQUE élément rendu, pas
seulement les élément de premier niveau) — et seulement les plus "hauts"
parmi eux, pour ne jamais régénérer un même nœud deux fois. Seul "mail
content" est donc désormais remplacé (via replaceWith), sans toucher au
Répéteur ni au reste de l'écran. La sauvegarde/restauration de l'état
visuel transitoire (style, classes, dataset hors clickBound/hoverBound/
hoverTriggerBound) suit le même principe, appliquée au nœud remplacé et
à ses descendants.

Aucun aller-retour réseau n'a jamais eu lieu ici (refreshRuntimeData()
utilise déjà fetch/JSON, pas de navigation de page) — la sensation de
rechargement venait uniquement de la granularité du remplacement DOM,
pas d'un manque d'AJAX.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:02:49 +02:00
williamandClaude Sonnet 5 5114372cc4 Réattache les gestionnaires de clic après "Ouvrir la ligne cliquée"
Root cause enfin identifiée grâce à un log console instrumenté par
l'utilisateur (indispensable — sans lui les précédents correctifs
visaient le mauvais chemin de code, refreshRuntimeData(), qui ne se
déclenchait même pas dans ce scénario) : l'action "Ouvrir la ligne
cliquée" (ouvrir_ligne) appelle applyOpenRowBindings() DIRECTEMENT,
sans jamais rappeler bindClicks() ensuite — contrairement à
refreshRuntimeData(), qui elle le fait déjà correctement.

Si l'écran a un Répéteur ET un panneau de détail (avec des {{champ}})
posés dans un même conteneur parent, applyOpenRowBindings() régénère
tout ce sous-arbre — Répéteur compris — pour résoudre les {{champ}} du
panneau. Les lignes du Répéteur héritent alors de nœuds DOM tout neufs,
sans le moindre écouteur de clic (le garde-fou anti-doublon de
bindClicks() repose sur dataset.clickBound, absent sur un nœud neuf,
mais bindClicks() lui-même n'était jamais rappelé pour les attacher).

Symptôme exact reproduit : le tout premier clic sur une ligne fonctionne
(gestionnaires posés au chargement de la page), plus AUCUN clic ne
répond ensuite sur AUCUNE ligne, sans erreur console — confirmé par un
log montrant runFlowFrom() jamais réinvoqué au clic suivant, et
manuellement réparé en rappelant bindClicks() à la main dans la
console.

Fix : bindClicks()/bindHoverTexts()/bindHoverTriggers() sont maintenant
rappelés juste après applyOpenRowBindings() dans le gestionnaire de
"Ouvrir la ligne cliquée", comme ils le sont déjà dans
refreshRuntimeData().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 08:18:43 +02:00
williamandClaude Sonnet 5 ca7daf9a31 Réattache toujours les gestionnaires de clic après un rafraîchissement de données
Test de diagnostic déterminant : après le blocage rapporté (plus aucune
ligne de Répéteur ne répond après le tout premier clic), appeler
manuellement bindClicks() dans la console suffisait à tout réparer —
donc ni le DOM ni le graphe de logique n'étaient en cause, seule
l'INVOCATION de bindClicks() manquait à un moment donné.

Cause : refreshRuntimeData() faisait un retour anticipé silencieux
(`if (!screenData || !screenDiv) return;`) qui sautait, avec lui,
TOUT le reste de la fonction — y compris bindClicks(), bindHoverTexts()
et bindHoverTriggers() — sans le moindre message d'erreur, laissant les
éléments régénérés (Répéteur compris) sans aucun écouteur pour le reste
de la partie.

Fix : ce garde-fou ne protège plus désormais que le bloc de régénération
du contenu de l'écran (qui a effectivement besoin de screenData/
screenDiv) ; les réattachements, eux, s'exécutent toujours ensuite, quoi
qu'il arrive. Un try/catch autour de la régénération ajoute en prime un
filet de sécurité : toute erreur inattendue s'y loggera clairement au
lieu de bloquer silencieusement le reste, si jamais ce n'était pas
l'unique cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 07:47:19 +02:00
williamandClaude Sonnet 5 024da11934 Corrige le blocage des clics après le premier rafraîchissement de données
Régression introduite par le commit précédent (préservation de l'état
visuel transitoire dans applyOpenRowBindings) : la restauration du
dataset complet d'un nœud copiait aussi clickBound/hoverBound/
hoverTriggerBound — des indicateurs INTERNES au moteur (voir bindClicks/
bindHoverTexts/bindHoverTriggers), jamais un état posé par une action
"Modifier un élément". Un nœud tout juste régénéré se retrouvait donc
marqué "déjà lié" à tort, alors qu'aucun écouteur de clic n'y était
réellement rattaché : bindClicks() le voyait déjà "bound" et sautait
son rattachement, rendant l'élément silencieusement inerte pour le
reste de la partie.

Symptôme rapporté : dans un écran avec un Répéteur ET un panneau de
détail utilisant des {{champ}}, le premier clic sur une ligne fonctionne
(exécuté par les gestionnaires posés au chargement de la page), mais
plus aucun clic ne répond ensuite sur AUCUNE ligne — le Répéteur étant
regénéré dans le même sous-arbre que le panneau de détail (ancêtre
commun avec des {{champ}} non résolus), donc concerné par la même
restauration de dataset.

Fix : exclure ces trois clés internes de la sauvegarde/restauration —
seul l'état réellement transitoire (style inline, classes, data-toggle-*
posés par "Modifier un élément") doit survivre à la regénération.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 07:31:40 +02:00
williamandClaude Sonnet 5 438e561b45 Préserver l'état visuel transitoire lors du rafraîchissement des données
Cause réelle du bug rapporté ("clic sur une ligne = comme un rechargement,
impossible de cliquer sur une deuxième ligne") : applyOpenRowBindings()
regénère entièrement le sous-arbre d'un élément contenant des {{champ}}
(ex. "mail content") à partir de son HTML D'ORIGINE, tel que rendu par le
serveur — donc avec son style PAR DÉFAUT (ici "invisible" via le réglage
Disposition > Visibilité). Cette fonction est appelée après CHAQUE
rafraîchissement de données (refreshRuntimeData), y compris pour un
changement de donnée sans rapport avec ce panneau.

Or une action "Modifier un élément → Rendre visible" ne modifie JAMAIS la
base : c'est un changement DOM transitoire (style.visibility = ''). Quand
un clic sur une ligne de Répéteur déclenche EN PARALLÈLE "Ouvrir la ligne
cliquée" + "Rendre visible" + "Modifier une donnée", la branche
"Modifier une donnée" est asynchrone (aller-retour serveur) et termine
après les deux autres, synchrones. Son refreshRuntimeData() qui suit
regénère alors "mail content" depuis son état par défaut, écrasant le
"Rendre visible" qui venait tout juste d'être posé — le panneau redevient
invisible. Un second clic sur le MÊME mail "corrige" l'affichage car la
donnée est déjà à jour, donc la Condition ne redéclenche plus l'action de
modification, plus de refresh, plus d'écrasement ; mais ouvrir un AUTRE
mail reproduisait le même écrasement.

Fix : avant de remplacer wrapper.innerHTML, sauvegarder le style inline,
la classe et les data-* de chaque élément du sous-arbre, puis les
réappliquer juste après la regénération — la résolution des {{champ}}
reste correcte (c'est le but premier de la fonction) sans plus annuler
les changements posés par une action "Modifier un élément" au même clic.

Les trois actions du graphe (ouvrir la ligne, rendre visible, modifier la
donnée) restent connectées telles quelles, sans aucun retrait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:17:35 +02:00
williamandClaude Sonnet 5 96d017c21f Évite de rejouer les déclencheurs "affichage"/l'animation quand on est déjà sur l'écran ciblé
Bug remonté : cliquer sur une ligne de Répéteur "semblait recharger la
page", et devenait impossible à recliquer une deuxième fois - alors que
le graphe de logique voulu (modifier la donnée + rendre visible un détail
+ "Ouvrir la ligne cliquée") reste sur le MÊME écran que le Répéteur.

Cause : showScreen() rejouait INCONDITIONNELLEMENT les déclencheurs "À
l'affichage de l'écran" et relançait la timeline d'animation depuis le
début à chaque appel - même quand l'écran cible est déjà celui affiché
(le cas normal pour "Ouvrir la ligne cliquée" combinée à une action
"Modifier un élément → Visibilité" sur le même clic, pensées pour
fonctionner ensemble SUR le même écran qu'un Répéteur). Ça rejouait donc
les animations d'entrée et pouvait faire repasser la visibilité à son
état initial via un déclencheur "affichage", entrant en conflit avec
l'action "Rendre visible" du même clic - d'où l'impression de
rechargement, et le blocage : reflow/re-rendu qui se disputent avec
l'état attendu.

Fix : showScreen() ne fait plus rien du tout si l'écran ciblé est déjà
celui affiché - aucun changement visuel à faire, donc aucune raison de
rejouer son "premier affichage". Changer vers un écran DIFFÉRENT continue
de tout rejouer normalement. Les trois actions du graphe restent
déclenchées à chaque clic, sans plus se marcher dessus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:47:25 +02:00
williamandClaude Sonnet 5 b094342097 Ajoute "Ligne cliquée (Répéteur)" comme cible pour une Condition/action de la Logique de la scène
Problème remonté : un déclencheur "Au clic" posé sur un Répéteur exécute
le MÊME graphe pour n'importe quelle ligne cliquée - or une Condition
("Si is_opened est égal à Non") ou une action "Modifier une donnée" ne
pouvaient viser qu'une ligne FIXE, choisie à la création du nœud dans
l'éditeur. Impossible donc de dire "modifie le champ DE LA LIGNE QUE JE
VIENS DE CLIQUER", puisque cette ligne n'est justement jamais connue à
l'avance.

Nouvelle valeur sentinelle CLICKED_ROW_ID (-1, screens/flow/constants.py,
ne collisionne jamais avec un vrai id de ligne) proposée en tête de TOUTE
liste déroulante "Ligne concernée" (clause principale et clauses
supplémentaires d'un nœud Condition, cible d'une action "Modifier une
donnée") : "🖱️ Ligne cliquée (Répéteur)".

Résolution au moment de l'exécution, pas à la création du nœud :
- Condition (évaluée côté client) : readFieldValue() (play.html) résout
  -1 en window.lastClickedRowId, déjà capturé par bindClicks() au clic sur
  une ligne de Répéteur (déjà utilisé par "Ouvrir la ligne cliquée").
- Action "Modifier une donnée" (exécutée côté serveur) : le client envoie
  clicked_row_id dans le corps de la requête POST ; flow_node_run_data.py
  ne s'en sert que si le nœud vise justement CLICKED_ROW_ID, sinon la
  ligne fixe stockée sur le nœud reste utilisée normalement.

Ajoute tests/test_flow_clicked_row.py (ligne cliquée seule modifiée,
absence de clic = no-op plutôt que plantage, non-régression d'une cible
fixe, présence de l'option dans l'éditeur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 17:19:41 +02:00
williamandClaude Sonnet 5 7d48445443 Ajoute "Variable globale" au sélecteur "Valeur fixe / Donnée d'un autre objet"
Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre
du Répéteur, Donnée liée, condition de visibilité) proposait déjà une
valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de
comparer à une variable globale (voir db/global_vars/), qui change elle
aussi en cours de partie mais n'est rattachée à aucun objet précis.

Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans
ambiguïté de "{{Objet.champ}}", qui attend toujours un point) :
_resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle
via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un
champ d'objet. Le panneau de propriétés gagne un troisième mode
"Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet",
avec la liste déroulante des variables existantes.

Corrige au passage un bug latent découvert en testant bout en bout : un
Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en
mode jouable avec TypeError - render_repeater.py substitue lui aussi
directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte
aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis
l'ajout de la condition de visibilité - déjà corrigé pour le chemin
générique (apply_ctx.py) mais pas pour ce chemin séparé.

Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans
le même message) existe déjà depuis un tour précédent (voir
insertFieldAtCursor()) - vérifié toujours fonctionnel.

Ajoute tests/test_filter_value_global_variable.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:51:18 +02:00
williamandClaude Sonnet 5 6894c5fc95 Remplace le confirm() natif par une modale custom qui avertit des suppressions en cascade
Depuis le dernier correctif, supprimer un élément supprime aussi en
silence tout nœud de la Logique de la scène qui le référence (déclencheur/
action, voir delete_element.py) - nécessaire pour éviter le plantage
"FOREIGN KEY constraint failed", mais l'utilisateur n'était jamais prévenu
qu'un bout de sa logique disparaissait en même temps.

Nouvelle route GET .../elements/<id>/delete-impact (element_delete_
impact.py) : calcule, sans rien supprimer, combien de nœuds de la Logique
de la scène référencent cet élément OU l'un de ses descendants (partage
element_descendant_ids.py avec delete_element.py, pour rester exactement
cohérent avec ce qui sera réellement supprimé).

Les deux boutons "supprimer" de screen_edit.html (élément sélectionné, et
onglet d'un widget Onglets) ouvrent maintenant une modale custom
(deleteConfirmModal, même famille que le sélecteur d'icônes) au lieu du
confirm() natif du navigateur : elle interroge cette route juste après
ouverture et affiche un avertissement dédié si le nombre remonté est non
nul, avant que l'utilisateur ne confirme quoi que ce soit - impossible à
faire avec confirm(), dont le texte est figé au moment du rendu de la
page. La confirmation soumet ensuite le formulaire normalement (via
requestSubmit(), intercepté par pjax.js comme n'importe quel autre
formulaire).

Ajoute deux tests pour la nouvelle route (impact nul, impact non nul sans
rien supprimer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:27:34 +02:00
williamandClaude Sonnet 5 1e86077e45 Corrige un plantage à la suppression d'un élément référencé par la Logique de la scène
Bug remonté : supprimer un élément plantait avec sqlite3.IntegrityError:
FOREIGN KEY constraint failed.

Cause : trigger_element_id/target_element_id (_flow_nodes, voir
ensure_flow_schema.py - un nœud "clic sur cet élément" ou "Modifier cet
élément"/"Activer cet onglet") et target_element_id (l'ancien système
_actions, conservé pour compatibilité) référencent _screen_elements(id)
SANS ON DELETE CASCADE - volontairement, un élément ne doit pas pouvoir
disparaître "par erreur" en cascade depuis un nœud de logique qu'on
modifie ailleurs. Mais ça veut dire que delete_element.py, qui ne
supprimait jusqu'ici que la ligne elle-même, faisait échouer PRAGMA
foreign_keys=ON (db/connection.py) dès qu'un nœud de logique existant
référençait encore l'élément.

Fix : delete_element.py nettoie maintenant ces références AVANT de
supprimer l'élément - pas seulement pour l'élément explicitement supprimé,
mais pour tous ses DESCENDANTS aussi (leur suppression est cascadée
automatiquement au niveau SQL via parent_id, sans repasser par ce
fichier, donc sans ce nettoyage si on ne le fait pas explicitement).

Ajoute tests/test_delete_element_referenced_by_flow.py (élément
référencé comme déclencheur, comme cible d'action, et cas d'un conteneur
supprimé dont un descendant est référencé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:19:52 +02:00
williamandClaude Sonnet 5 4dc42a7f3b Corrige les réglages fantômes d'un exemplaire d'élément de jeu déjà posé
Bug remonté : la taille d'un Titre réglée à 18px dans le modèle affichait
21px "sur l'écran". Cause : un exemplaire posé AVANT le passage en mode
"toujours lié au modèle" (tour précédent) avait été créé par l'ancien
mécanisme instantiate_template_tree (retiré), qui copiait tout l'arbre en
base - ces lignes copiées (l'ancien enfant "Titre", encore à 21px depuis
avant le changement) ne sont plus jamais RENDUES (le contenu vient
désormais toujours en direct du modèle, voir le tour précédent), mais
restaient toujours sélectionnables dans l'arborescence de l'éditeur, avec
leurs propres réglages jamais synchronisés. Cliquer dessus dans l'arbre
affichait donc ses vieux réglages (21px) dans le panneau de propriétés,
donnant l'impression trompeuse que le modèle (18px) n'était pas pris en
compte - alors que le rendu réel utilisait déjà correctement 18px.

Fix, dans list_elements.py : tout élément dont un ANCÊTRE a
element_type_id réglé est maintenant exclu de la liste renvoyée à
l'éditeur (arborescence, sélection, panneau de propriétés) - son contenu
n'a plus aucune existence propre, seul l'écran-modèle fait foi. Les
lignes elles-mêmes restent en base (pas de suppression, un simple filtre
en lecture) mais ne sont plus jamais atteignables depuis l'éditeur.

Ajoute un test de régression qui simule exactement ce scénario (ligne
orpheline avec un ancien réglage figé) et vérifie qu'elle n'apparaît plus
nulle part - ni dans le canevas, ni via une sélection directe par id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:10:30 +02:00
williamandClaude Sonnet 5 94f1414506 Corrige un bug important : le style de TOUT élément neuf était cassé depuis l'ajout de l'Ombre
Bug remonté ("toutes les propriétés ne sont pas prises en compte") : la
vraie cause n'avait rien à voir avec les éléments de jeu — le contrôle
"Ombre" (voir le tour précédent) a une valeur par défaut composite (un
dict Python, {"x":0,"y":4,...}, pas une chaîne CSS). default_style_for_
widget.py, qui fige les réglages "dont la valeur par défaut a un effet
visuel voulu dès la création" pour chaque widget neuf, n'excluait pas ce
nouveau type de contrôle — il écrivait donc ce dict TEL QUEL (repr Python)
dans le style de CHAQUE élément fraîchement créé depuis ce commit, quel
que soit son widget. Une valeur CSS invalide au milieu du style pouvait
donner l'impression que "plein de propriétés" ne s'appliquaient plus.

Deux correctifs :
1. default_style_for_widget.py exclut maintenant "shadow" du gel à la
   création (même raisonnement déjà appliqué à "color" juste au-dessus :
   la valeur par défaut n'est qu'une suggestion affichée dans le panneau,
   pas un réglage neutre à figer - le neutre CSS est "pas d'ombre").
2. style_string.py ignore désormais toute valeur non scalaire (dict/liste)
   au moment de construire l'attribut style - filet de sécurité pour les
   éléments déjà créés AVANT ce correctif, qui portent encore ce dict figé
   en base et continueraient sinon à s'afficher cassés.

Ajoute deux tests de régression dans test_shadow_controls.py (aucune ombre
au premier rendu d'un élément neuf ; un élément déjà corrompu avant ce
correctif continue de s'afficher normalement).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:54:06 +02:00
williamandClaude Sonnet 5 e9a991ed13 Un exemplaire d'élément de jeu posé sur un écran reste maintenant lié à son modèle
Jusqu'ici, poser un élément de jeu depuis le catalogue ("Mes éléments de
jeu") copiait tout son arbre en base (instantiate_template_tree) : chaque
exemplaire devenait indépendant, y compris de son propre modèle - modifier
l'élément de jeu dans son éditeur n'avait plus aucun effet sur les
exemplaires déjà posés ailleurs.

Change ce comportement pour qu'un exemplaire reste TOUJOURS lié à son
modèle, sur le même principe déjà utilisé par un modèle de ligne de
Répéteur (jamais copié, rechargé en direct à chaque affichage - voir
_load_template_tree/_render_repeater) : add_element.py ne crée plus
qu'UNE SEULE ligne plate (avec sa position/taille propres à cet
exemplaire) au lieu de copier tout l'arbre, et render_element_html.py
recharge le contenu depuis l'écran-modèle à chaque rendu quand
element_type_id est réglé. Modifier l'élément de jeu dans son propre
éditeur met donc à jour tous ses exemplaires déjà posés, sur n'importe
quel écran (y compris ceux placés AVANT ce correctif, qui portaient déjà
element_type_id sur leur ligne de premier niveau), sans avoir à les
retoucher un par un.

Contrepartie assumée (discutée avec l'utilisateur avant ce changement) :
un exemplaire ne peut plus être personnalisé individuellement à
l'INTÉRIEUR (texte, couleur d'un enfant précis...) - seules sa position et
sa taille sur l'écran restent propres à chaque exemplaire. Pour changer le
contenu, il faut désormais passer par l'éditeur de l'élément de jeu
lui-même.

instantiate_template_tree.py, devenu inutilisé, est supprimé.

Ajoute tests/test_element_type_live_instances.py (mise à jour d'un
exemplaire déjà posé, propagation jusqu'à "Jouer", position toujours
indépendante par exemplaire) et met à jour un commentaire de test devenu
obsolète dans test_screens_and_elements.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:26:45 +02:00
williamandClaude Sonnet 5 79fe04c6ac Corrige "database is locked" causé par une connexion SQLite qui fuit après un plantage
Bug remonté : suppression d'un élément échouant avec
sqlite3.OperationalError: database is locked, exactement sur le
conn.execute() de delete_element.py. La trace complète montrait que la
connexion attendait puis lâchait après le timeout (10s) - pas une simple
collision passagère entre deux requêtes (déjà gérée par WAL + busy_timeout,
voir les commentaires existants de connect()), mais un verrou tenu bien
plus longtemps : une connexion ouverte par une requête ANTÉRIEURE qui a
planté, jamais fermée.

Cause de fond : chaque fonction de db/ (~80 d'entre elles) ouvre sa propre
connexion et est censée la fermer elle-même avant de rendre la main - si
une exception survient entre l'ouverture et cette fermeture, le
conn.close() prévu n'est jamais atteint. En mode debug (voir app.py), le
débogueur Werkzeug garde la trace complète de l'erreur en mémoire pour
l'inspection interactive, ce qui inclut la variable locale `conn` :
empêchée d'être ramassée par le GC, elle ne libère jamais son verrou
d'écriture SQLite - bloquant TOUTE écriture suivante jusqu'au redémarrage
du serveur, même longtemps après l'erreur d'origine et sans lien apparent
avec elle (d'où la confusion : l'erreur semble venir de l'action qui
échoue, alors qu'elle est victime d'une fuite antérieure).

Fix, dans db/connection.py, sans toucher aux ~80 fonctions existantes :
connect() enregistre maintenant chaque connexion sur le contexte de la
requête Flask en cours (flask.g, uniquement quand il y en a un - un appel
direct hors requête, scripts/tests, n'est pas concerné) ; un
teardown_request ferme toute connexion encore ouverte à la fin de CHAQUE
requête, qu'elle ait réussi ou planté (garanti par Flask, contrairement à
after_request). Fermer une connexion déjà fermée normalement ne fait
rien, donc aucun changement de comportement pour le cas normal.

Ajoute tests/test_db_connection_leak_safety_net.py, qui reproduit le
scénario exact (connexion ouverte puis exception avant fermeture) et
vérifie qu'une écriture suivante ne bloque plus - désactivé temporairement
pour confirmer que le test échoue bien (et de la même façon) sans le fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:06:16 +02:00
williamandClaude Sonnet 5 bda082bffd Ajoute une propriété "Ombre portée" (box-shadow), disponible sur tout widget
Nouveau groupe "Ombre" dans le panneau de propriétés, à côté de "Bordure"
(même universalité — voir UNIVERSAL_CONTROLS) : décalage X/Y, flou,
étendue, couleur et opacité, combinés en une seule valeur CSS box-shadow
(couleur+opacité fusionnées en rgba(), un <input type="color"> seul ne
portant pas de canal alpha). Décalages/flou/étendue tous à 0 = pas
d'ombre, même convention que border-width à 0 = pas de bordure.

Nouveau ctype "shadow" (c_shadow.py), suit exactement le même principe
que le ctype "size" déjà existant (size_override_controls.py) : plusieurs
entrées de formulaire pour un seul réglage, composées/décomposées dans
save_element_controls.py et control_value.py plutôt que passées par le
chemin générique clé->valeur.

Ajoute tests/test_shadow_controls.py (présence dans le panneau,
composition de la valeur CSS, aller-retour dans le formulaire, remise à
zéro qui retire l'ombre).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:37:38 +02:00
williamandClaude Sonnet 5 9c9e3c5379 Le sélecteur de champ apparaît maintenant sur tout nouveau widget d'un élément de jeu lié à un objet
Le tour précédent exigeait que CHAQUE widget règle sa propre "Donnée
liée" (_data_definition_id) pour voir apparaître le sélecteur de champ -
mais un élément de jeu ("Mail card"...) créé avec un "Objet lié" (voir
element_types.html, bound_definition_id) a précisément pour but d'éviter
ce réglage widget par widget : ses {{champ}} sont censés venir de CET
objet, fourni plus tard par le Répéteur qui l'utilisera comme modèle de
ligne. D'où le bug remonté : un nouveau Titre/Texte posé dans un tel
élément de jeu n'affichait jamais le sélecteur.

Ajoute get_element_type_by_template_screen(slug, screen_id), pour
retrouver depuis l'éditeur d'un écran-modèle l'entrée du catalogue (et
donc l'objet lié) dont il est la recette. screen_edit.py le calcule pour
le panneau de propriétés et le passe à controls_with_values(), qui
l'utilise comme repli pour le champ "Contenu" SEULEMENT si ce widget n'a
pas déjà sa propre "Donnée liée" réglée (priorité conservée au réglage le
plus spécifique).

Ajoute tests/test_element_type_bound_field_picker.py (apparition sans
réglage supplémentaire, absence sans objet lié, priorité à la "Donnée
liée" du widget si réglée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:38:10 +02:00
williamandClaude Sonnet 5 7e80ff952a Ajoute un sélecteur de champ pour insérer {{champ}} dans le Contenu
Une fois "Lier à un objet de données" réglé (widget Texte/Titre...),
propose maintenant les champs de CET objet en liste déroulante juste sous
le champ "Contenu", avec un bouton "+ Ajouter" qui insère "{{nom_du_champ}}"
à l'emplacement du curseur - plutôt que d'avoir à taper cette syntaxe à
la main sans savoir quels noms de champs existent réellement.

controls_with_values.py pose field_options sur le contrôle "content"
quand _data_definition_id est réglé (réutilise data_binding_options, déjà
calculé pour data_filtre_champ/data_filtre2_champ) ; insertFieldAtCursor()
(screen_edit.html) fait l'insertion via selectionStart/selectionEnd puis
déclenche un événement "input" pour que l'enregistrement automatique se
déclenche normalement, comme une saisie manuelle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 13:18:12 +02:00
williamandClaude Sonnet 5 baa3da1035 Corrige la comparaison booléenne : "Oui"/"Non" n'était pas reconnu comme valeur vraie/fausse
Bug remonté avec une "Mail card" (icône enveloppe fermée si is_opened
est à "Non", ouverte si "Oui") : les DEUX variantes s'affichaient (ou
aucune), selon la ligne.

Cause : _compare() (filter_repeater_rows.py, utilisé par la condition de
visibilité, le Répéteur et Donnée liée) ne reconnaissait "1"/"true"/"vrai"
comme valeur vraie pour un champ booléen — jamais "oui", pourtant le SEUL
vocabulaire que l'app affiche elle-même pour ce type de champ partout
ailleurs (voir data_list.html : "Oui" si vrai sinon "Non"). Une valeur de
comparaison fixe tapée "Oui" retombait donc silencieusement à "faux",
et comme l'opérateur et le champ étaient par ailleurs corrects, ça
donnait l'impression que la condition "ne voyait" rien : sur la ligne où
is_opened=faux, les DEUX cartes ("égal à Oui" et "égal à Non", toutes
deux évaluées comme "égal à faux") s'affichaient ensemble ; sur la ligne
où is_opened=vrai, aucune des deux.

Fix : "oui" ajouté à l'ensemble des valeurs reconnues comme vraies,
côté Python (_compare) ET côté JS (compareValues() dans play.html, qui
doit rester alignée — utilisée par les nœuds Condition de la Logique de
la scène), cette dernière au passage rendue insensible à la casse comme
son équivalent Python (elle ne l'était pas du tout).

Ajoute un test de régression dédié à ce cas précis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:52:27 +02:00
williamandClaude Sonnet 5 a28cc5881e Corrige la condition de visibilité : un widget "special_render" visible ne se rafraîchissait jamais en jeu
Bug remonté avec deux icônes (enveloppe fermée / ouverte) posées
directement sur l'écran, chacune conditionnée sur is_opened : après avoir
ouvert le mail (une action "Modifier une donnée"), les DEUX icônes
restaient affichées en même temps au lieu que l'ouverte remplace la fermée.

Cause : refreshRuntimeData() (play.html) ne réévalue, après une action,
que les éléments dont le HTML porte un marqueur ("visibilityGated"/
"repeaterItem"/"jaugeBar"). render_element_html() posait bien ce marqueur
quand un élément sous condition est actuellement visible - mais seulement
sur le chemin de rendu GÉNÉRIQUE (texte, titre, conteneur...), jamais sur
les 9 widgets "special_render" (Icône, Tableau, Superposition, Onglets,
Case à cocher, Liste déroulante, Groupe de champs, Répéteur, Jauge) - un
élément CACHÉ portait toujours son marqueur (via son placeholder), mais un
élément VISIBLE de ce type non. Résultat : l'icône "fermée", visible au
premier chargement, ne portait aucun marqueur et restait donc figée dans
son état d'origine après toute action suivante, pendant que l'icône
"ouverte" (cachée au départ, donc marquée) se mettait, elle, correctement
à jour - d'où les deux affichées ensemble.

Fix : les 9 branches special_render passent maintenant, elles aussi, par
_mark() comme le chemin générique.

Ajoute un test de régression dédié (icône visible sous condition = doit
porter le marqueur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:31:50 +02:00
williamandClaude Sonnet 5 ff01b3903c Corrige la condition de visibilité (mode "objet") à l'intérieur d'un Répéteur
Bug remonté : dans une "Mail card" (élément de jeu réutilisable, deux
icônes - enveloppe fermée/ouverte - conditionnées sur le champ is_opened
de l'objet Email) posée dans un Répéteur de données, rien ne s'affichait
jamais correctement.

Cause : is_element_visible() (mode "objet") allait toujours chercher en
base la ligne la plus récente de l'objet ciblé (convention "1 seule ligne
= état de partie", correcte pour une Jauge suivant un état de partie),
sans jamais tenir compte de la ligne EN COURS DE RENDU dans un Répéteur -
donc tous les exemplaires du même modèle de ligne évaluaient la MÊME
ligne (la plus récente de tout l'objet Email) au lieu de chacun la
sienne, et affichaient donc tous exactement le même résultat.

Fix : is_element_visible() reçoit maintenant le ctx de rendu (les
{{champ}} de la ligne en cours, déjà posés par render_repeater.py) et,
si le champ réglé s'y trouve, utilise directement cette valeur plutôt que
d'interroger la base - un exemplaire de Répéteur voit donc bien SA propre
ligne. Hors Répéteur, le comportement (ligne la plus récente de l'objet)
est inchangé.

Ajoute tests/test_visibility_condition.py (mode variable, mode objet hors
Répéteur, absence dans l'éditeur, et ce cas précis dans un Répéteur) -
cette fonctionnalité n'avait jusqu'ici aucun test persistant, seulement
des scripts ad-hoc jetés après vérification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 12:17:40 +02:00
williamandClaude Sonnet 5 11b08c8503 Éditeur de scène : le canevas remplit exactement l'espace disponible, plus de marges
Le calcul JS "object-fit:contain" du tour précédent gardait l'aspect-ratio
(Portrait/Paysage/Carré) au prix de marges vides sur les côtés dès que la
fenêtre n'avait pas exactement ce ratio - "prendre toute la place
disponible" et "garder l'aspect-ratio" sont deux exigences contradictoires
dans ce cas, et c'est la première qui doit l'emporter dans l'éditeur.

Le canevas (#canvas) remplit donc maintenant .canvasFrame à 100% x 100%,
sans plus tenir compte de l'aspect-ratio choisi dans l'éditeur - ce
réglage continue de s'appliquer normalement à l'aperçu jouable ("Jouer",
voir screen_set_aspect.py/play.html), qui reste la référence pour le
rendu final. Padding de .canvasFrame réduit au minimum. fitCanvasToFrame()
et son calcul en pixels n'ont plus lieu d'être - retirés entièrement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:50:50 +02:00
williamandClaude Sonnet 5 14deba943a Éditeur de scène : le canevas remplit vraiment tout l'espace disponible
Le calcul purement CSS du tour précédent (aspect-ratio + height:100% +
max-width:100%, pour que le canevas garde ses proportions tout en tenant
dans la zone visible) laissait en pratique le canevas bien plus petit que
l'espace réellement disponible - le calcul de taille "auto" d'un élément
non remplacé dans ce contexte flex n'est pas fiable.

Remplacé par un calcul en JavaScript (fitCanvasToFrame()) : mesure la
taille réelle de .canvasFrame et calcule la plus grande taille en pixels
qui tient à la fois en largeur ET en hauteur pour l'aspect-ratio courant
(l'équivalent d'un "object-fit:contain"), posée directement en style
inline sur #canvas. Recalculé à l'ouverture de l'écran, au changement de
format (Portrait/Paysage/Carré), au redimensionnement de la fenêtre, et à
chaque rafraîchissement du panneau (#canvas étant recréé à chaque
sélection d'élément, sa taille calculée était perdue à chaque fois).
Sous 1300px (mise en page empilée), le style inline est explicitement
effacé pour laisser la règle CSS de repli (pleine largeur, page qui
défile normalement) reprendre la main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:44:40 +02:00
williamandClaude Sonnet 5 dae6da166e Éditeur de scène : supprime le fil d'Ariane, canevas remonté, défilement propre au canevas
Le fil d'Ariane disparaît entièrement sur cette page (breadcrumb_wrap vide)
- le nom de l'écran est de toute façon déjà éditable juste au-dessus, dans
le panneau "Éléments" (voir le tour précédent) - et .content-builder perd
son padding par défaut hérité de .content, pour que le canevas commence le
plus haut possible.

Change aussi la façon dont le canevas est dimensionné : il était
jusqu'ici contraint par la LARGEUR (width:100%), ce qui pouvait le rendre
bien plus haut que la fenêtre pour un format Portrait - obligeant à
défiler .builderCanvasArea (toolbar/onglets compris) pour voir le bas de
l'écran. Il est maintenant contraint par la HAUTEUR disponible
(height:100%, la largeur se déduisant de l'aspect-ratio), avec
max-width:100% en secours si c'est la largeur qui manque en premier -
l'écran entier reste donc visible sans défiler. Le défilement, s'il reste
nécessaire (fenêtre très basse), se fait maintenant sur .canvasFrame
lui-même, jamais sur .builderCanvasArea (repassé à overflow:hidden) : la
barre d'onglets et la barre d'outils restent toujours fixes en haut.

Ajoute le pendant pour le repli en page empilée (< 1300px, une seule
colonne) : le "letterboxing" par hauteur suppose une chaîne de hauteurs
définies qui n'existe plus une fois empilé - revient alors à un
dimensionnement par largeur, cohérent avec une page qui défile normalement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:38:29 +02:00
williamandClaude Sonnet 5 ea9f91fc19 Éditeur de scène : Logique/Timeline en onglets plein écran, réglages d'écran déplacés dans le panneau Éléments
Remplace l'ancien panneau du bas rétractable/redimensionnable à la souris
(Logique de la scène, Timeline d'animation) par un système d'onglets au
centre de l'éditeur - Écran / Logique de la scène / Timeline d'animation
- un seul visible à la fois, occupant systématiquement tout l'espace
disponible (switchBuilderTab() dans screen_edit.html). Changer d'onglet
équivaut à "fermer" celui qu'on quitte ; plus besoin d'une poignée de
redimensionnement séparée puisque l'onglet actif prend déjà toute la
place. Supprime au passage tout l'ancien mécanisme (toggleFlowPanel/
toggleAnimPanel, poignées flowResizeHandle/animResizeHandle, classe CSS
.flowPanel) devenu inutile.

Déplace aussi le renommage de l'écran, le bouton "Jouer depuis le début"
et le choix du format d'aperçu (Portrait/Paysage/Carré) - jusqu'ici
au-dessus du canevas - dans le panneau flottant "Éléments" (celui qui
porte déjà ce nom, à gauche) : des réglages qu'on touche rarement une
fois l'écran en construction, qui n'ont plus besoin de rester en
permanence visibles au-dessus de la zone de travail.

Corrige au passage un bug découvert pendant ce tour : sur la page "Nouvel
objet" (2 colonnes), le bouton "+ Ajouter un champ" avait disparu -
placé APRÈS la zone de liste à défilement (flex-grow:1) dans la colonne
de droite, un flex-grow imprévisible dans ce contexte le poussait hors de
vue. Déplacé avant la liste (statique, toujours visible), pattern déjà
éprouvé ailleurs sur cette même page.

Deux tests mis à jour pour refléter intentionnellement la nouvelle
structure : la présence de "animTabPanel" (au lieu de l'ancien
"animPanel"), et un marqueur plus précis pour distinguer le bloc de
propriétés "Onglets" d'une simple occurrence du même texte dans une liste
déroulante de la Logique de la scène (qui apparaît désormais plus tôt
dans le document, cet éditeur de flow étant maintenant un onglet du
centre plutôt qu'un panneau tout en bas de page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:27:50 +02:00
williamandClaude Sonnet 5 2051b375ac Passe toutes les pages restantes en deux colonnes (créer à gauche, contenu à droite)
Généralise le principe déjà utilisé pour le tableau de bord et l'édition
d'objet à toutes les pages restantes : liste des jeux, écrans, éléments de
jeu, variables, nouvel objet, formulaire de données, tableau des données
d'un objet - colonne de gauche pour créer/agir, colonne de droite pour ce
qui existe déjà, chaque colonne défilant pour son propre compte.

Pour un formulaire qui doit rester UN SEUL <form> à cheval sur les deux
colonnes (nouvel objet : nom à gauche, champs à droite ; formulaire de
données : bouton Enregistrer à gauche, champs à droite), nouvelle classe
.formPassthrough ("display:contents") : le <form> ne devient pas lui-même
une boîte dans la mise en page flex, seul .twoCol à l'intérieur compte.

Supprime au passage .content-page/.formScroll/body.pageBody (le gabarit à
une colonne introduit au tour précédent, plus utilisé nulle part) et
.list/.listRow/.listRowFlex/.rowTitle/.rowSub/.rowActions/.addBtn (les
cartes à deux lignes remplacées par les tableaux denses et la nav
compacte) - du CSS mort plutôt que deux systèmes qui se chevauchent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:58:49 +02:00
williamandClaude Sonnet 5 59247694d6 Applique le gabarit sobre/compact/sans défilement à toutes les pages hors éditeur
Généralise le principe déjà en place pour l'édition d'objet (la page
n'occupe jamais plus que la hauteur de la fenêtre, seules ses zones
internes défilent) à toutes les pages restantes : liste des jeux, tableau
de bord d'un jeu, écrans, éléments de jeu, variables, nouvel objet,
formulaire de données, tableau des données d'un objet. Seuls l'éditeur
d'écran/d'élément et l'aperçu jouable restent en dehors (déjà exclus par
leur propre gabarit plein écran, ou pas concernés).

Nouveau gabarit générique à une colonne (body.pageBody + .content-page +
.scrollArea) sur le même principe que .content-objectEdit, plus une
variante .formScroll pour un formulaire long à défilement interne (liste
de champs d'un nouvel objet, formulaire de données) tout en gardant les
boutons d'action toujours visibles. Les listes/tableaux remplacés par le
format dense .fieldsTable (déjà utilisé pour le tableau de bord) pour
rester cohérent et afficher plus de lignes à l'écran.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:45:42 +02:00
williamandClaude Sonnet 5 ac0f003663 Rend le tableau de bord du jeu plus compact, sobre et sans défilement de page
Réutilise le système déjà en place pour l'édition d'objet (body.objectEditBody
+ .content-objectEdit) : la page occupe exactement la hauteur de la fenêtre,
seules les deux colonnes défilent chacune de leur côté si besoin - plus la
page elle-même, qui ne doit jamais défiler.

Colonne de gauche ("Créer") : nouvelle nav à une ligne (.navRow/.navList,
icône + libellé, sous-titre en info-bulle) plutôt que des cartes à deux
lignes - plus dense, plus sobre.

Colonne de droite ("Objets définis") : remplace les cartes par le tableau
compact à en-tête collant déjà utilisé pour les champs d'un objet
(.fieldsTable) - format nettement plus adapté à beaucoup de lignes.

Réduit aussi le padding par défaut de .listRow/.dangerZone (encore utilisés
par la page Variables), dans le même esprit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:31:43 +02:00
williamandClaude Sonnet 5 20ef86e039 Aligne le style du bouton "nouvel objet" sur les autres actions du tableau de bord
Remplace le bouton en pointillés ("+ Définir un nouvel objet") par une
entrée de liste identique aux autres actions (Écrans, Variables, Jouer...)
— plus cohérent visuellement, comme demandé après revue du rendu réel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:24:07 +02:00
williamandClaude Sonnet 5 11a2d397e8 Remplace la création inline de variable par une page de gestion dédiée, redesign du tableau de bord du jeu
Retrait de la création rapide de variable globale depuis le sélecteur de
la Condition de visibilité (bouton "+ Créer") : une variable globale est
désormais gérée comme un objet "jeu" à part entière, avec une vraie page
CRUD ("Variables", nouvelle entrée du menu de gauche) - création, édition
du type/valeur, suppression. Le nom reste volontairement immuable après
création (c'est par ce nom qu'une condition de visibilité ou une action
"Modifier une variable" la référence - la renommer casserait ces réglages
en silence), d'où db.update_global_variable qui ne touche que type/valeur.

Redesign du tableau de bord du jeu (game_dashboard.html) en deux
colonnes : à gauche tout ce qu'on peut créer (écrans, éléments de jeu,
variables, jouer, nouvel objet) plus les paramètres du jeu (renommer/
supprimer) ; à droite ce qui a déjà été créé (objets définis). Remplace
les cartes Bulma par le système de mise en page compact déjà défini dans
style.css (.twoCol/.listRow/.dangerZone/.addBtn) mais jamais utilisé
jusqu'ici - plus dense et cohérent avec le reste de l'éditeur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 10:00:35 +02:00
williamandClaude Sonnet 5 c04bc0b926 Ajoute la condition de visibilité et les variables globales
Nouveau panneau "Condition de visibilité" disponible dans les propriétés
de TOUT élément (widget) : permet de masquer un élément en mode jouable
selon deux moyens, au choix -
  - une variable globale (nom + type + valeur, une seule par jeu, stockée
    dans une nouvelle table _global_variables) ;
  - le champ d'un objet de données existant (même convention "état de
    partie" - une seule ligne - déjà utilisée par la Jauge).

Une variable ne servant à rien si elle ne peut jamais changer en cours de
partie, ajoute aussi une nouvelle action de flow "Modifier une variable
globale" (parallèle à "Modifier une donnée"), avec sa propre route
d'exécution serveur et son sous-formulaire dans l'éditeur de logique de
scène. Une variable peut aussi se créer à la volée depuis le sélecteur du
panneau de visibilité, sans quitter les propriétés de l'élément.

La condition n'est évaluée qu'en mode jouable (/game/<slug>/play), jamais
dans l'éditeur, pour que l'élément reste toujours sélectionnable. Un
élément masqué se réévalue en direct après toute action "Modifier une
donnée/variable", via le même mécanisme de rafraîchissement déjà utilisé
par la Jauge et le Répéteur.

Corrige au passage deux bugs découverts en testant bout en bout : (1)
apply_ctx plantait sur le nouveau marqueur interne _forge_play_mode (un
booléen parmi les {{champ}} à substituer, qui attend des chaînes) ; (2)
_compare traitait toute valeur booléenne stockée en chaîne ("0" inclus,
donc toujours vraie en Python) comme vraie - correct pour les champs
d'objet (entiers SQLite) mais faux pour les variables globales (toujours
stockées en texte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:34:10 +02:00
william 3c6d07cb3a Remove the "Ajouter DANS ce conteneur" properties-panel menu
Redundant since dragging an element onto a container row in the tree now does the same thing (see move_element_to_container.py). The element_add_child route itself stays (still used by tests and available as an API), only the UI entry point is gone. Removed the now-dead .containerContentGroup CSS along with it.
2026-08-25 07:22:09 +02:00
william aa3be503ce Expose relation fields in the champ pickers and fix their column resolution
data_definition_options() used to exclude relation-type fields from its field list entirely, so a filter/binding could never reference "the linked object" of a row — and even when a relation field's clean name was typed manually (as the earlier "level.parcour" example needed), it silently matched nothing: every column lookup for a filter/repeater field used the field's own name, but a relation is actually stored in a "<field>_id" column (see create_definition.py), so the lookup always missed.

Relation fields now appear in the champ dropdowns (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) labeled with the object they point to (e.g. "parcour (→ parcours)"), and a new _field_column() helper in filter_repeater_rows.py resolves the right "<field>_id" column whenever the field turns out to be a relation — used consistently by the filter comparison itself, the "{{Objet.champ}}" dynamic-value resolver, the repeater's row content ({{champ}}), and the Donnée liée row context. Jauge's own champ/champ_nom pickers (which need an actual displayable value, not an id) still exclude relations, both server- and client-side.

Verified end to end: the dropdown shows the relation field with its target-object label, and filtering "level" rows by the clean relation field name "parcour" (not "parcour_id") against a dynamic {{game.current_parcours}} reference now actually matches, alongside the existing "number" filter. Full suite green (89).
2026-08-25 06:48:04 +02:00
william 343732d7aa Turn free-typed field/object names into dropdowns in the Répéteur and Donnée liée filters
Two settings previously required typing an exact field or object name by hand:

- "champ" (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) is now a dropdown of the actual fields on the object already chosen for that widget — pre-populated server-side (controls_with_values.py) and live-updated client-side without a reload when you change the object (bindDefinitionFieldSelects(), generalizing the existing Jauge champ/row_id pattern).

- "valeur" (the comparison value, which could already reference another object's field via a "{{Objet.champ}}" string typed by hand) is now a "Valeur fixe" / "Donnée d'un autre objet" toggle — the dynamic mode shows an object dropdown and a dependent field dropdown, and picking from them reconstructs that internal reference string automatically (initFilterValuePickers()/updateFilterValueFromDynamic()). The underlying stored value and _resolve_filter_value's parsing are unchanged, so existing elements using the old typed syntax still load correctly and populate the pickers on open.

Guards against writing a literal "{{...}}" pair directly in the Jinja template source (caught by a real render error while building this — Jinja parses {{/}} anywhere in the file, including inside comments) by building that syntax from split string literals in JS instead.

Verified end to end: repeater panel renders the pickers, saving with a dynamic reference filters correctly against live data (current_level changing which row shows), and reloading pre-selects the right object/field in the dropdowns. Full suite green (89).
2026-08-25 06:32:21 +02:00
william 33a887e7ba Move icon gallery to the left panel, drop the redundant Icône tile, add drag-into-container in the tree
- The "icone" widget no longer appears as a generic tile in "Ajouter un élément sur l'écran" / "Ajouter DANS ce conteneur" — the icon gallery (now living under "Ajouter un élément sur l'écran" in the left panel instead of the right one) is the only way to add one, always pre-set to the icon you picked.
- The element tree now supports dragging an element onto a container row to move it inside (last child), from anywhere in the tree — not just reordering within the same parent. Hovering a container row splits it into before/after/inside zones (thirds) when dragging a sibling, or "inside only" when dragging from elsewhere in the tree. New move_element_to_container() rejects non-container targets and cycles (dropping a container into itself or one of its own descendants) silently, mirroring reorder_element's existing safety pattern.

Verified end to end: gallery renders in the left panel with no icone tile in the widget grids, and the move endpoint correctly reparents, rejects a cycle, and rejects a non-container target. Full suite green (89).
2026-08-25 06:03:02 +02:00