From 024da11934b508c36a1139013bf3dccf9f2111f5 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 07:31:40 +0200 Subject: [PATCH 01/24] =?UTF-8?q?Corrige=20le=20blocage=20des=20clics=20ap?= =?UTF-8?q?r=C3=A8s=20le=20premier=20rafra=C3=AEchissement=20de=20donn?= =?UTF-8?q?=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/templates/play.html b/templates/play.html index ab635e79..1fce822e 100644 --- a/templates/play.html +++ b/templates/play.html @@ -355,10 +355,22 @@ // recrée entièrement ses nœuds à partir de leur état PAR DÉFAUT. const savedState = {}; wrapper.querySelectorAll('[data-element-id]').forEach(function(node) { + // clickBound/hoverBound/hoverTriggerBound (voir bindClicks/ + // bindHoverTexts/bindHoverTriggers) sont des indicateurs INTERNES du + // moteur, jamais un état posé par une action "Modifier un élément" — + // les restaurer sur le nœud tout neuf créé ci-dessous lui ferait + // croire à tort qu'un écouteur de clic/survol lui est déjà rattaché + // (alors qu'aucun ne l'est réellement), ce qui bloquait + // silencieusement TOUS les clics suivants sur cet élément (ex. les + // lignes d'un Répéteur voisin, une fois régénérées avec lui). + const dataset = Object.assign({}, node.dataset); + delete dataset.clickBound; + delete dataset.hoverBound; + delete dataset.hoverTriggerBound; savedState[node.dataset.elementId] = { style: node.getAttribute('style') || '', className: node.className, - dataset: Object.assign({}, node.dataset), + dataset: dataset, }; }); -- 2.54.0 From ca7daf9a31d99234ce6cfb98e51e0f10d37e370a Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 07:47:19 +0200 Subject: [PATCH 02/24] =?UTF-8?q?R=C3=A9attache=20toujours=20les=20gestion?= =?UTF-8?q?naires=20de=20clic=20apr=C3=A8s=20un=20rafra=C3=AEchissement=20?= =?UTF-8?q?de=20donn=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 48 +++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/templates/play.html b/templates/play.html index 1fce822e..f6945494 100644 --- a/templates/play.html +++ b/templates/play.html @@ -411,21 +411,39 @@ screensData = gameData.screens; const screenData = screensData.find(s => s.id === window.currentScreenId); const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]'); - if (!screenData || !screenDiv) return; - screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { - if (!el.rendered_html) return; - // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée - // à une donnée) et "visibilityGated" (condition de visibilité — - // voir render_element_html.py) sont les types d'éléments dont le - // contenu/l'affichage dépend de la donnée elle-même plutôt que - // d'une action "Modifier un élément" posée à la main dans le - // graphe — eux seuls doivent être régénérés après un changement - // de donnée ou de variable. - if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return; - const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner'); - if (wrapper) wrapper.innerHTML = el.rendered_html; - }); - applyOpenRowBindings(); + // Le bloc ci-dessous (régénération du contenu de l'écran courant) + // est sauté si l'écran n'est pas trouvé, mais bindClicks() et les + // autres réattachements juste après ne doivent JAMAIS dépendre de + // cette condition : un retour anticipé ici a déjà laissé, dans la + // pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire + // de clic pour le reste de la partie (plus aucune ligne ne + // répondait, silencieusement, sans erreur) — ces fonctions sont sans + // risque à rappeler même quand rien n'a changé. + if (screenData && screenDiv) { + // try/catch : une erreur inattendue pendant la régénération (ex. + // structure de données imprévue) ne doit jamais empêcher les + // réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou + // tout autre élément cliquable) reste silencieusement inerte pour + // le reste de la partie, sans le moindre message d'erreur visible. + try { + screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { + if (!el.rendered_html) return; + // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée + // à une donnée) et "visibilityGated" (condition de visibilité — + // voir render_element_html.py) sont les types d'éléments dont le + // contenu/l'affichage dépend de la donnée elle-même plutôt que + // d'une action "Modifier un élément" posée à la main dans le + // graphe — eux seuls doivent être régénérés après un changement + // de donnée ou de variable. + if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return; + const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner'); + if (wrapper) wrapper.innerHTML = el.rendered_html; + }); + applyOpenRowBindings(); + } catch (err) { + console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err); + } + } bindClicks(); bindHoverTexts(); bindHoverTriggers(); -- 2.54.0 From 5114372cc4002ad1623aa3a23f3b389a7d41e4fc Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 08:18:43 +0200 Subject: [PATCH 03/24] =?UTF-8?q?R=C3=A9attache=20les=20gestionnaires=20de?= =?UTF-8?q?=20clic=20apr=C3=A8s=20"Ouvrir=20la=20ligne=20cliqu=C3=A9e"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/templates/play.html b/templates/play.html index f6945494..356428c3 100644 --- a/templates/play.html +++ b/templates/play.html @@ -563,6 +563,18 @@ } showScreen(node.target_screen_id); applyOpenRowBindings(); + // Si l'élément "ouvert" (ou un ancêtre commun avec lui) contient AUSSI + // un Répéteur — ex. un panneau de détail posé à côté d'une liste dans + // le même conteneur — applyOpenRowBindings() vient de régénérer son + // sous-arbre, lignes du Répéteur comprises, avec des nœuds tout neufs + // SANS AUCUN écouteur de clic (voir bindClicks : le garde-fou anti- + // doublon repose sur elDiv.dataset.clickBound, absent sur un nœud + // fraîchement créé). Sans ce rappel, un clic sur "ouvrir la ligne + // cliquée" combiné à un Répéteur sur le même écran rendait TOUTES les + // lignes muettes dès le clic suivant, sans la moindre erreur. + bindClicks(); + bindHoverTexts(); + bindHoverTriggers(); return Promise.resolve(); } else if (node.action_type === 'modifier_element' && node.target_element_id) { const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]'); -- 2.54.0 From 20adfa416911c691b5b07df3447942ff9f220a93 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 09:02:49 +0200 Subject: [PATCH 04/24] =?UTF-8?q?Ne=20r=C3=A9g=C3=A9n=C3=A8re=20plus=20que?= =?UTF-8?q?=20l'=C3=A9l=C3=A9ment=20concern=C3=A9=20par=20"Ouvrir=20la=20l?= =?UTF-8?q?igne=20cliqu=C3=A9e"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 69 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/templates/play.html b/templates/play.html index 356428c3..8b65705f 100644 --- a/templates/play.html +++ b/templates/play.html @@ -339,30 +339,63 @@ const screenData = screensData.find(s => s.id === window.currentScreenId); const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]'); if (!row || !screenData || !screenDiv) return; - screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { - const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner'); - if (!wrapper || !el.rendered_html || el.rendered_html.indexOf(PLACEHOLDER_OPEN) === -1) return; - // Avant de regénérer ce sous-arbre depuis le HTML D'ORIGINE (nécessaire - // pour ne pas laisser les valeurs d'un mail précédent affichées, voir - // plus haut), sauvegarde l'état visuel TRANSITOIRE posé par une action + // Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les + // éléments qui portent vraiment un {{champ}} non résolu — et non plus + // seulement les éléments de PREMIER NIVEAU de l'écran. Avant ce + // correctif, un panneau de détail niché à 2-3 niveaux (ex. "mail + // content" posé dans un conteneur, lui-même dans un autre) forçait à + // regénérer TOUT son ancêtre de premier niveau — potentiellement + // l'écran entier (jauges, onglets, Répéteur compris) s'il n'y avait + // qu'un seul gros conteneur racine — d'où l'impression trompeuse d'un + // rechargement de page (flash à vide) à chaque clic sur une ligne. + // + // Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que + // les plus "hauts" dans l'arbre : si un ancêtre ET un de ses + // descendants sont tous deux candidats, seul l'ancêtre est régénéré + // (son rendered_html inclut déjà ce descendant) — sans ce filtre, le + // descendant serait régénéré deux fois, la 2e fois à partir du DOM déjà + // à jour de la 1re passe. + const byId = {}; + screenData.elements.forEach(function(e){ byId[e.id] = e; }); + function hasUnresolvedPlaceholder(e) { + return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1); + } + function hasCandidateAncestor(e) { + let p = e.parent_id ? byId[e.parent_id] : null; + while (p) { + if (hasUnresolvedPlaceholder(p)) return true; + p = p.parent_id ? byId[p.parent_id] : null; + } + return false; + } + const targets = screenData.elements.filter(function(e) { + return hasUnresolvedPlaceholder(e) && !hasCandidateAncestor(e); + }); + + targets.forEach(function(el) { + const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]'); + if (!target) return; + + // Avant de regénérer ce nœud depuis le HTML D'ORIGINE (nécessaire pour + // ne pas laisser les valeurs d'un mail précédent affichées, voir plus + // haut), sauvegarde l'état visuel TRANSITOIRE posé par une action // "Modifier un élément" (jamais enregistré en base — voir - // applyElementProperty) sur cet élément ou l'un de ses descendants, - // pour le réappliquer après. Sans ça, un panneau rendu visible par - // cette action (ex. "mail content") redevenait invisible à CHAQUE + // applyElementProperty) sur ce nœud ou l'un de ses descendants, pour + // le réappliquer après. Sans ça, un panneau rendu visible par cette + // action (ex. "mail content") redevenait invisible à CHAQUE // rafraîchissement de données, y compris pour un changement de donnée - // sans rapport avec lui — puisque wrapper.innerHTML = html ci-dessous - // recrée entièrement ses nœuds à partir de leur état PAR DÉFAUT. + // sans rapport avec lui — puisque le remplacement ci-dessous recrée + // entièrement ses nœuds à partir de leur état PAR DÉFAUT. const savedState = {}; - wrapper.querySelectorAll('[data-element-id]').forEach(function(node) { + [target].concat(Array.from(target.querySelectorAll('[data-element-id]'))).forEach(function(node) { // clickBound/hoverBound/hoverTriggerBound (voir bindClicks/ // bindHoverTexts/bindHoverTriggers) sont des indicateurs INTERNES du // moteur, jamais un état posé par une action "Modifier un élément" — // les restaurer sur le nœud tout neuf créé ci-dessous lui ferait // croire à tort qu'un écouteur de clic/survol lui est déjà rattaché // (alors qu'aucun ne l'est réellement), ce qui bloquait - // silencieusement TOUS les clics suivants sur cet élément (ex. les - // lignes d'un Répéteur voisin, une fois régénérées avec lui). + // silencieusement TOUS les clics suivants sur cet élément. const dataset = Object.assign({}, node.dataset); delete dataset.clickBound; delete dataset.hoverBound; @@ -382,9 +415,13 @@ tmp.textContent = value; html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML); }); - wrapper.innerHTML = html; + const container = document.createElement('div'); + container.innerHTML = html; + const newNode = container.firstElementChild; + if (!newNode) return; + target.replaceWith(newNode); - wrapper.querySelectorAll('[data-element-id]').forEach(function(node) { + [newNode].concat(Array.from(newNode.querySelectorAll('[data-element-id]'))).forEach(function(node) { const saved = savedState[node.dataset.elementId]; if (!saved) return; node.setAttribute('style', saved.style); -- 2.54.0 From f4a7a7340e66ba7ac0a96555ff219a3e9d418a63 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 12:51:57 +0200 Subject: [PATCH 05/24] =?UTF-8?q?Cible=20aussi=20les=20=C3=A9l=C3=A9ments?= =?UTF-8?q?=20imbriqu=C3=A9s=20dans=20refreshRuntimeData()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 55 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/templates/play.html b/templates/play.html index 8b65705f..69369ca4 100644 --- a/templates/play.html +++ b/templates/play.html @@ -463,18 +463,49 @@ // tout autre élément cliquable) reste silencieusement inerte pour // le reste de la partie, sans le moindre message d'erreur visible. try { - screenData.elements.filter(function(e){ return !e.parent_id; }).forEach(function(el) { - if (!el.rendered_html) return; - // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée - // à une donnée) et "visibilityGated" (condition de visibilité — - // voir render_element_html.py) sont les types d'éléments dont le - // contenu/l'affichage dépend de la donnée elle-même plutôt que - // d'une action "Modifier un élément" posée à la main dans le - // graphe — eux seuls doivent être régénérés après un changement - // de donnée ou de variable. - if (el.rendered_html.indexOf('repeaterItem') === -1 && el.rendered_html.indexOf('jaugeBar') === -1 && el.rendered_html.indexOf('visibilityGated') === -1) return; - const wrapper = screenDiv.querySelector('.playElement[data-el-id="' + el.id + '"] .playElementInner'); - if (wrapper) wrapper.innerHTML = el.rendered_html; + // "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée + // à une donnée) et "visibilityGated" (condition de visibilité — + // voir render_element_html.py) sont les types d'éléments dont le + // contenu/l'affichage dépend de la donnée elle-même plutôt que + // d'une action "Modifier un élément" posée à la main dans le + // graphe — eux seuls doivent être régénérés après un changement + // de donnée ou de variable. + // + // On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication + // (pas seulement le premier niveau de l'écran), via leur + // data-element-id — disponible sur chaque élément rendu, pas + // seulement les éléments de premier niveau. Avant ce correctif, + // un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS + // du premier niveau, donc c'est son ANCÊTRE de premier niveau + // qui portait le marqueur (le Répéteur imbriqué à l'intérieur) + // et se retrouvait régénéré en entier à sa place — potentiellement + // l'écran entier (jauges, onglets... compris) s'il n'y avait + // qu'un seul gros conteneur racine. Comme pour + // applyOpenRowBindings(), on ne garde que les éléments marqués + // les plus "hauts" dans l'arbre, pour ne jamais régénérer un même + // nœud deux fois. + const byId = {}; + screenData.elements.forEach(function(e){ byId[e.id] = e; }); + function hasMarker(e) { + return !!(e.rendered_html && (e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1)); + } + function hasMarkedAncestor(e) { + let p = e.parent_id ? byId[e.parent_id] : null; + while (p) { + if (hasMarker(p)) return true; + p = p.parent_id ? byId[p.parent_id] : null; + } + return false; + } + screenData.elements.filter(function(e) { + return hasMarker(e) && !hasMarkedAncestor(e); + }).forEach(function(el) { + const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]'); + if (!target) return; + const container = document.createElement('div'); + container.innerHTML = el.rendered_html; + const newNode = container.firstElementChild; + if (newNode) target.replaceWith(newNode); }); applyOpenRowBindings(); } catch (err) { -- 2.54.0 From 5e4e2267949de8257c1812c7e07840f9e7286ac8 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 26 Aug 2026 13:04:01 +0200 Subject: [PATCH 06/24] =?UTF-8?q?Corrige=20le=20filtre=20"=C3=A9l=C3=A9men?= =?UTF-8?q?t=20le=20plus=20sp=C3=A9cifique"=20(logique=20invers=C3=A9e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/play.html | 49 +++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/templates/play.html b/templates/play.html index 69369ca4..7e611467 100644 --- a/templates/play.html +++ b/templates/play.html @@ -351,26 +351,33 @@ // rechargement de page (flash à vide) à chaque clic sur une ligne. // // Parmi les éléments qui ont un {{champ}} non résolu, on ne garde que - // les plus "hauts" dans l'arbre : si un ancêtre ET un de ses - // descendants sont tous deux candidats, seul l'ancêtre est régénéré - // (son rendered_html inclut déjà ce descendant) — sans ce filtre, le - // descendant serait régénéré deux fois, la 2e fois à partir du DOM déjà - // à jour de la 1re passe. + // les plus SPÉCIFIQUES (les plus profonds) : si un élément ET l'un de + // ses DESCENDANTS sont tous deux candidats, seul le descendant est + // régénéré (rendered_html d'un conteneur inclut TOUJOURS celui de ses + // descendants, donc un conteneur "candidat" ne l'est souvent que parce + // qu'un descendant à lui l'est — le garder en plus regénérerait + // inutilement tout son sous-arbre, à l'exact opposé du but recherché). + // ATTENTION : la version précédente de ce filtre vérifiait l'inverse + // (exclure un élément si un ANCÊTRE est candidat) — comme un ancêtre + // est presque toujours "candidat" dès qu'un descendant l'est (son HTML + // le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine + // de l'écran, annulant complètement l'effet recherché par ce correctif. const byId = {}; screenData.elements.forEach(function(e){ byId[e.id] = e; }); function hasUnresolvedPlaceholder(e) { return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1); } - function hasCandidateAncestor(e) { + function isDescendantOf(e, ancestorId) { let p = e.parent_id ? byId[e.parent_id] : null; while (p) { - if (hasUnresolvedPlaceholder(p)) return true; + if (p.id === ancestorId) return true; p = p.parent_id ? byId[p.parent_id] : null; } return false; } - const targets = screenData.elements.filter(function(e) { - return hasUnresolvedPlaceholder(e) && !hasCandidateAncestor(e); + const candidates = screenData.elements.filter(hasUnresolvedPlaceholder); + const targets = candidates.filter(function(e) { + return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); }); }); targets.forEach(function(el) { @@ -480,25 +487,33 @@ // qui portait le marqueur (le Répéteur imbriqué à l'intérieur) // et se retrouvait régénéré en entier à sa place — potentiellement // l'écran entier (jauges, onglets... compris) s'il n'y avait - // qu'un seul gros conteneur racine. Comme pour - // applyOpenRowBindings(), on ne garde que les éléments marqués - // les plus "hauts" dans l'arbre, pour ne jamais régénérer un même - // nœud deux fois. + // qu'un seul gros conteneur racine. + // + // Parmi les éléments marqués, on ne garde que les plus + // SPÉCIFIQUES (les plus profonds) : un ancêtre est presque + // toujours "marqué" lui aussi dès qu'un descendant l'est, puisque + // son rendered_html contient celui de ses descendants — le + // garder EN PLUS régénérerait tout son sous-arbre pour rien. + // (Une version antérieure de ce filtre vérifiait l'inverse — + // exclure un élément si un ANCÊTRE est marqué — ce qui ne + // gardait en pratique que l'ancêtre racine de l'écran et + // annulait complètement l'effet recherché.) const byId = {}; screenData.elements.forEach(function(e){ byId[e.id] = e; }); function hasMarker(e) { return !!(e.rendered_html && (e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1)); } - function hasMarkedAncestor(e) { + function isDescendantOf(e, ancestorId) { let p = e.parent_id ? byId[e.parent_id] : null; while (p) { - if (hasMarker(p)) return true; + if (p.id === ancestorId) return true; p = p.parent_id ? byId[p.parent_id] : null; } return false; } - screenData.elements.filter(function(e) { - return hasMarker(e) && !hasMarkedAncestor(e); + const markedCandidates = screenData.elements.filter(hasMarker); + markedCandidates.filter(function(e) { + return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); }); }).forEach(function(el) { const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]'); if (!target) return; -- 2.54.0 From f016a81dc610c0421851e6d0ad49a100100b26bf Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 06:24:42 +0200 Subject: [PATCH 07/24] =?UTF-8?q?R=C3=A9sout=20{{champ}}=20en=20place=20au?= =?UTF-8?q?=20lieu=20de=20recr=C3=A9er=20le=20n=C5=93ud=20(z=C3=A9ro=20fla?= =?UTF-8?q?sh,=20fa=C3=A7on=20React)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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- 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 --- templates/play.html | 126 ++++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 52 deletions(-) diff --git a/templates/play.html b/templates/play.html index 7e611467..e1db84c6 100644 --- a/templates/play.html +++ b/templates/play.html @@ -380,61 +380,83 @@ return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); }); }); + // Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais + // détruire/recréer le moindre nœud — un peu comme un diff React ciblé, + // mais posé à la main puisqu'il n'y a pas de vrai DOM virtuel ici : + // - dans un nœud TEXTE : {{champ}} est coupé en 3 (texte avant, un + // qui portera la valeur, texte + // après) LA PREMIÈRE FOIS SEULEMENT ; les ouvertures suivantes se + // contentent de changer le textContent de ce span. + // - dans un ATTRIBUT (ex. href d'un lien "{{link_real_url}}") : le + // gabarit d'origine est mémorisé sur un data-bind-attr- lors du + // premier passage, pour pouvoir recalculer et réécrire l'attribut à + // chaque fois sans jamais avoir besoin de reconstruire le nœud. + // Avant ce correctif, tout le sous-arbre ciblé était détruit et recréé + // depuis zéro à chaque clic sur une ligne — ce qui causait un flash + // visuel (perte de tout état transitoire posé par "Modifier un + // élément", donnant l'impression trompeuse d'un rechargement de page). + // Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge : + // rien à sauvegarder ni à restaurer. + const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g'); + + function resolveTextNodes(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const toSplit = []; + let node; + while ((node = walker.nextNode())) { + if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node); + } + toSplit.forEach(function(textNode) { + const raw = textNode.nodeValue; + placeholderRe.lastIndex = 0; + const frag = document.createDocumentFragment(); + let lastIndex = 0, match; + while ((match = placeholderRe.exec(raw))) { + if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index))); + const span = document.createElement('span'); + span.dataset.bindField = match[1]; + span.textContent = row[match[1]] == null ? '' : String(row[match[1]]); + frag.appendChild(span); + lastIndex = match.index + match[0].length; + } + if (lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex))); + textNode.replaceWith(frag); + }); + } + + function updateTextSlots(root) { + root.querySelectorAll('[data-bind-field]').forEach(function(slot) { + const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]); + if (slot.textContent !== value) slot.textContent = value; + }); + } + + function resolveAttributes(root) { + [root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) { + Array.from(node.attributes).forEach(function(attr) { + if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire + const bindKey = 'data-bind-attr-' + attr.name; + const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null); + if (template === null) return; + if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template); + placeholderRe.lastIndex = 0; + const resolved = template.replace(placeholderRe, function(_, fieldName) { + return row[fieldName] == null ? '' : String(row[fieldName]); + }); + if (attr.value !== resolved) node.setAttribute(attr.name, resolved); + }); + }); + } + targets.forEach(function(el) { const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]'); if (!target) return; - - // Avant de regénérer ce nœud depuis le HTML D'ORIGINE (nécessaire pour - // ne pas laisser les valeurs d'un mail précédent affichées, voir plus - // haut), sauvegarde l'état visuel TRANSITOIRE posé par une action - // "Modifier un élément" (jamais enregistré en base — voir - // applyElementProperty) sur ce nœud ou l'un de ses descendants, pour - // le réappliquer après. Sans ça, un panneau rendu visible par cette - // action (ex. "mail content") redevenait invisible à CHAQUE - // rafraîchissement de données, y compris pour un changement de donnée - // sans rapport avec lui — puisque le remplacement ci-dessous recrée - // entièrement ses nœuds à partir de leur état PAR DÉFAUT. - const savedState = {}; - [target].concat(Array.from(target.querySelectorAll('[data-element-id]'))).forEach(function(node) { - // clickBound/hoverBound/hoverTriggerBound (voir bindClicks/ - // bindHoverTexts/bindHoverTriggers) sont des indicateurs INTERNES du - // moteur, jamais un état posé par une action "Modifier un élément" — - // les restaurer sur le nœud tout neuf créé ci-dessous lui ferait - // croire à tort qu'un écouteur de clic/survol lui est déjà rattaché - // (alors qu'aucun ne l'est réellement), ce qui bloquait - // silencieusement TOUS les clics suivants sur cet élément. - const dataset = Object.assign({}, node.dataset); - delete dataset.clickBound; - delete dataset.hoverBound; - delete dataset.hoverTriggerBound; - savedState[node.dataset.elementId] = { - style: node.getAttribute('style') || '', - className: node.className, - dataset: dataset, - }; - }); - - let html = el.rendered_html; - Object.keys(row).forEach(function(fieldName) { - if (fieldName === 'id') return; - const value = row[fieldName] == null ? '' : String(row[fieldName]); - const tmp = document.createElement('div'); - tmp.textContent = value; - html = html.split(PLACEHOLDER_OPEN + fieldName + PLACEHOLDER_CLOSE).join(tmp.innerHTML); - }); - const container = document.createElement('div'); - container.innerHTML = html; - const newNode = container.firstElementChild; - if (!newNode) return; - target.replaceWith(newNode); - - [newNode].concat(Array.from(newNode.querySelectorAll('[data-element-id]'))).forEach(function(node) { - const saved = savedState[node.dataset.elementId]; - if (!saved) return; - node.setAttribute('style', saved.style); - node.className = saved.className; - Object.assign(node.dataset, saved.dataset); - }); + if (target.querySelector('[data-bind-field]')) { + updateTextSlots(target); + } else { + resolveTextNodes(target); + } + resolveAttributes(target); }); } -- 2.54.0 From 19d38101642fd2e75d74309ee29d76cea49e9ee3 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 06:49:41 +0200 Subject: [PATCH 08/24] =?UTF-8?q?Calcule=20rendered=5Fhtml=20pour=20CHAQUE?= =?UTF-8?q?=20=C3=A9l=C3=A9ment,=20pas=20seulement=20le=20premier=20niveau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- screens/elements/list_elements.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/screens/elements/list_elements.py b/screens/elements/list_elements.py index 9025346b..cc755cae 100644 --- a/screens/elements/list_elements.py +++ b/screens/elements/list_elements.py @@ -7,12 +7,21 @@ from ..rendering.render_element_html import render_element_html def list_elements(slug, screen_id, enforce_visibility=False): """Renvoie TOUS les éléments de l'écran, à plat (parent_id indique - l'imbrication) — pratique pour construire l'arbre côté appelant. Seuls - les éléments de premier niveau (parent_id NULL) reçoivent un - "rendered_html" : celui d'un conteneur/répéteur/groupe de champs inclut - déjà, en le générant récursivement, le HTML de tout ce qui est posé à - l'intérieur — les enfants n'ont donc pas besoin d'être dessinés une - deuxième fois séparément. + l'imbrication) — pratique pour construire l'arbre côté appelant. CHAQUE + élément (premier niveau ou imbriqué) reçoit son propre "rendered_html" : + celui d'un conteneur/répéteur/groupe de champs inclut, en le générant + récursivement, le HTML de tout ce qui est posé à l'intérieur — un + élément imbriqué s'y retrouve donc 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) : c'est ce + dédoublement délibéré qui permet à play.html (voir applyOpenRowBindings/ + refreshRuntimeData) de régénérer ou patcher UN SEUL élément imbriqué + (ex. un panneau de détail posé dans un conteneur) sans reconstruire tout + son ancêtre de premier niveau — potentiellement l'écran entier. Avant ce + changement, seuls les éléments de premier niveau recevaient un + rendered_html ; un élément imbriqué n'en avait AUCUN côté client, qui + retombait donc toujours sur la régénération de son ancêtre de premier + niveau, quelle que soit la finesse du ciblage côté JS. enforce_visibility : True uniquement pour le mode jouable (voir full_game_payload.py) — applique la "condition de visibilité" de chaque @@ -66,6 +75,5 @@ def list_elements(slug, screen_id, enforce_visibility=False): children_map.setdefault(d["parent_id"], []).append(d) play_ctx = {"_forge_play_mode": True} if enforce_visibility else None for d in result: - if not d.get("parent_id"): - d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx) + d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx) return result -- 2.54.0 From 17c5e93d8d826960688704c7e460c6fd660c1c1d Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 07:33:25 +0200 Subject: [PATCH 09/24] =?UTF-8?q?Fait=20fonctionner=20logique=20et=20anima?= =?UTF-8?q?tions=20d'un=20mod=C3=A8le=20r=C3=A9utilisable=20partout=20o?= =?UTF-8?q?=C3=B9=20il=20est=20pos=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- screens/payload/full_game_payload.py | 15 +++- templates/play.html | 110 ++++++++++++++++++++------- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index 9f4cc791..8be22e47 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -5,6 +5,7 @@ from ..elements.list_elements import list_elements from ..flow.list_flow_nodes import list_flow_nodes from ..flow.list_flow_edges import list_flow_edges from ..animations.list_animation_clips import list_animation_clips +from ..element_types.list_element_types import list_element_types def full_game_payload(slug): @@ -42,4 +43,16 @@ def full_game_payload(slug): ] fields_meta[str(d["id"])] = [{"name": f["name"], "type": f["type"]} for f in full["fields"]] - return {"screens": payload_screens, "flows": flows, "animations": animations, "data": data, "fields_meta": fields_meta} + # element_type_id -> id de son écran-modèle : nécessaire côté client (voir + # collectAnimationClips() dans play.html) pour savoir, quand un élément de + # jeu réutilisable (ex. "mail content") est posé sur un écran, quels + # autres clips d'animation (ceux de SON PROPRE écran-modèle) doivent + # aussi être joués à l'affichage de cet écran — sans quoi une animation + # posée directement dans l'éditeur du modèle ne se jouait jamais quand le + # modèle est utilisé ailleurs. + element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)} + + return { + "screens": payload_screens, "flows": flows, "animations": animations, + "element_types": element_types, "data": data, "fields_meta": fields_meta, + } diff --git a/templates/play.html b/templates/play.html index e1db84c6..b49828f0 100644 --- a/templates/play.html +++ b/templates/play.html @@ -163,33 +163,69 @@ } function applyAnimationClip(clip) { - var target = document.querySelector('[data-anim-target="' + clip.element_id + '"]'); - if (!target) return; + // querySelectorAll (pas querySelector) : un clip posé dans l'éditeur d'un + // écran-MODÈLE (élément de jeu réutilisable) vise l'un de ses propres + // enfants — un id qui se répète à l'IDENTIQUE à chaque exemplaire de ce + // modèle (rechargé en direct depuis le même écran-modèle, voir + // collectAnimationClips ci-dessous), y compris pour chaque ligne d'un + // Répéteur utilisant ce modèle comme gabarit. Un seul querySelector + // n'aurait animé que le tout premier exemplaire trouvé dans le DOM. + var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]'); + if (!targets.length) return; var duration = clip.duration || 1, delay = clip.delay || 0; var iterationCount = clip.iteration_count || '1'; var infinite = iterationCount === 'infinite'; - if (clip.kind === 'custom') { - target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') + - ' ' + delay + 's ' + iterationCount; - if (!infinite) { - target.addEventListener('animationend', function handler() { - target.style.animation = ''; - target.removeEventListener('animationend', handler); - }); - } - } else { - var animClass = 'animate__' + clip.animate_name; - target.style.setProperty('--animate-duration', duration + 's'); - target.style.setProperty('--animate-delay', delay + 's'); - target.style.animationIterationCount = iterationCount; - target.classList.add('animate__animated', animClass); - if (!infinite) { - target.addEventListener('animationend', function handler() { - target.classList.remove('animate__animated', animClass); - target.removeEventListener('animationend', handler); - }); + targets.forEach(function(target) { + if (clip.kind === 'custom') { + target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') + + ' ' + delay + 's ' + iterationCount; + if (!infinite) { + target.addEventListener('animationend', function handler() { + target.style.animation = ''; + target.removeEventListener('animationend', handler); + }); + } + } else { + var animClass = 'animate__' + clip.animate_name; + target.style.setProperty('--animate-duration', duration + 's'); + target.style.setProperty('--animate-delay', delay + 's'); + target.style.animationIterationCount = iterationCount; + target.classList.add('animate__animated', animClass); + if (!infinite) { + target.addEventListener('animationend', function handler() { + target.classList.remove('animate__animated', animClass); + target.removeEventListener('animationend', handler); + }); + } } + }); + } + + // Rassemble les clips propres à cet écran ET ceux de tout écran-MODÈLE + // utilisé par un de ses éléments (élément de jeu réutilisable posé sur cet + // écran, ex. "mail content") — récursif (un modèle peut lui-même utiliser + // un autre modèle), avec une garde anti-boucle et un dédoublonnage par + // écran (un même modèle posé plusieurs fois ne doit pas répéter ses + // clips). Sans ça, une animation posée directement dans l'éditeur d'un + // modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul + // l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer. + function collectAnimationClips(screenId) { + var visited = {}; + var clips = []; + function visit(sid) { + if (visited[sid]) return; + visited[sid] = true; + clips = clips.concat((gameData.animations || {})[String(sid)] || []); + var screenData = screensData.find(function(s){ return String(s.id) === String(sid); }); + if (!screenData) return; + screenData.elements.forEach(function(el) { + if (!el.element_type_id) return; + var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)]; + if (templateScreenId != null) visit(templateScreenId); + }); } + visit(screenId); + return clips; } function runAnimationTimeline(screenId) { @@ -200,7 +236,7 @@ // suite sur le même écran avant la fin de sa timeline. pendingAnimationTimers.forEach(function(t) { clearTimeout(t); }); pendingAnimationTimers = []; - var clips = (gameData.animations || {})[String(screenId)] || []; + var clips = collectAnimationClips(screenId); clips.forEach(function(clip) { var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000); pendingAnimationTimers.push(t); @@ -233,11 +269,20 @@ // fois, sur tous les éléments de tous les écrans (déjà tous présents dans // le DOM, seuls les écrans non actifs sont masqués) — chaque identifiant // d'élément est unique dans tout le jeu, donc pas d'ambiguïté possible. + // Cette recherche globale couvre aussi les écrans-MODÈLES (un élément de + // jeu réutilisable, ex. "mail content", posé sur une scène) : un + // déclencheur "Au clic"/"Au survol" posé dans l'éditeur du modèle, sur un + // de SES PROPRES enfants, est donc retrouvé même quand ce modèle est + // utilisé ailleurs — d'où le besoin de renvoyer aussi l'écran d'origine du + // nœud (screenIdKey), pas seulement le nœud : runFlowFrom() doit exécuter + // le graphe dans CET écran-là (celui du modèle), jamais dans celui + // réellement affiché, sans quoi le nœud trouvé n'existerait pas dans le + // graphe utilisé et l'action ne se déclencherait jamais. function findTriggerNode(elementId, event) { for (const screenIdKey in gameData.flows) { const flow = gameData.flows[screenIdKey]; const found = flow.nodes.find(n => n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event); - if (found) return found; + if (found) return { node: found, screenId: screenIdKey }; } return null; } @@ -290,7 +335,7 @@ const rowEl = ev.target.closest('.repeaterItem'); window.lastClickedRowId = rowEl ? parseInt(rowEl.dataset.rowId, 10) : null; window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null; - runFlowFrom(trigger.id); + runFlowFrom(trigger.node.id, trigger.screenId); }); } }); @@ -311,8 +356,8 @@ const leaveTrigger = findTriggerNode(elementId, 'fin_survol'); if (!enterTrigger && !leaveTrigger) return; elDiv.dataset.hoverTriggerBound = '1'; - if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.id)); - if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.id)); + if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId)); + if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId)); }); } @@ -819,8 +864,15 @@ // seulement le premier. Chaque nœud Action produit son effet puis // continue sur son (ou ses) fil de sortie ; chaque nœud Condition évalue // une donnée du jeu et suit le(s) fil(s) "Vrai" ou "Faux" en conséquence. - function runFlowFrom(nodeId) { - const flow = gameData.flows[String(window.currentScreenId)]; + // flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut + // l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode) + // quand le nœud de départ vient d'un déclencheur posé dans l'éditeur d'un + // élément de jeu réutilisable, sur un de ses propres enfants. Sans ce + // paramètre, un tel déclencheur était bien retrouvé (findTriggerNode + // cherche dans tous les écrans) mais son graphe n'existait jamais dans + // l'écran réellement affiché : rien ne se déclenchait, silencieusement. + function runFlowFrom(nodeId, flowScreenId) { + const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)]; if (!flow) return; const nodesById = {}; flow.nodes.forEach(n => { nodesById[n.id] = n; }); -- 2.54.0 From e653f95d37e961700f12abd61de2e68bb52a9c57 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 07:41:48 +0200 Subject: [PATCH 10/24] =?UTF-8?q?Ouvre=20les=20onglets=20Logique/Animation?= =?UTF-8?q?=20dans=20l'=C3=A9diteur=20d'un=20mod=C3=A8le=20r=C3=A9utilisab?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- templates/screen_edit.html | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 6d4102a7..45a873e0 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -160,20 +160,23 @@ + switchBuilderTab() plus bas). Présents aussi sur un écran-modèle + (élément de jeu réutilisable, ex. "mail content") : ses enfants + gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur + n'importe quelle scène (rechargés en direct depuis ce modèle à + chaque affichage, voir _render_element_type_children côté serveur — + l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose, + instantiate_template_tree, a été retiré). Un déclencheur/une + animation posé ici, sur un enfant du modèle, s'exécute donc bien + partout où ce modèle est utilisé (voir findTriggerNode/ + collectAnimationClips dans templates/play.html). -->
{% set current_aspect = screen.preview_aspect or '9/16' %} - {% if not screen.is_template %}
- {% endif %}
@@ -190,7 +193,6 @@

Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.

- {% if not screen.is_template %}
@@ -449,7 +451,6 @@
- {% endif %}
+ +{% endif %} {% block breadcrumb_wrap %}
{% if game is defined and game %} - + {% endif %} -{% block breadcrumb_wrap %} - -{% endblock %}
{% block content %}{% endblock %}
diff --git a/templates/data_form.html b/templates/data_form.html index 88cc759c..da8898a0 100644 --- a/templates/data_form.html +++ b/templates/data_form.html @@ -2,11 +2,6 @@ {% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • {{ definition.name }}
  • -
  • {{ 'Modifier #' ~ row.id if row else 'Nouvelle entrée' }}
  • -{% endblock %} {% block content %}

    {{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}

    diff --git a/templates/data_list.html b/templates/data_list.html index 0f9855e1..8a045664 100644 --- a/templates/data_list.html +++ b/templates/data_list.html @@ -2,10 +2,6 @@ {% block title %}{{ definition.name }} — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • {{ definition.name }}
  • -{% endblock %} {% block content %}

    {{ definition.name }} ✏️ diff --git a/templates/element_types.html b/templates/element_types.html index 7e0c26bb..6cdf30e2 100644 --- a/templates/element_types.html +++ b/templates/element_types.html @@ -2,10 +2,6 @@ {% block title %}Éléments de jeu — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • Éléments de jeu
  • -{% endblock %} {% block content %}

    Éléments de jeu

    Un élément de jeu est un conteneur réutilisable.

    diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index 54fb5922..aae0ef67 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -1,63 +1,100 @@ {% extends "base.html" %} {% block title %}{{ game.name }} — Forge Engine{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %}
  • {{ game.name }}
  • {% endblock %} +{% block content_class %}content-wide{% endblock %} {% block content %}

    {{ game.name }}

    Base de données : projects/{{ game.slug }}/game.db

    -
    -
    -

    Créer

    - - -
    -

    Paramètres du jeu

    -
    - -
    - - -
    -
    -
    - -
    -
    -
    - -
    -

    Objets définis

    - {% if definitions %} -
    - - - - - - {% for d in definitions %} - - - - - - {% endfor %} - -
    NomTable SQL
    {{ d.name }}{{ d.table_name }} - ✏️ -
    +
    +
    +

    🖥️ Écrans du jeu {{ screen_list|length }}

    + {% if screen_list %} +
    + {% for s in screen_list %} + + {{ s.name }} + {% if loop.first %}★ Départ{% endif %} + + {% endfor %}
    {% else %} -

    Aucun objet défini pour l'instant — un objet, c'est comme une table de base de données : un nom, et des champs typés.

    +

    Aucun écran pour l'instant.

    {% endif %} + +
    + +
    +

    🗂️ Objets {{ definitions|length }}

    + {% if definitions %} +
    + {% for d in definitions %} + + {{ d.name }} + {{ d.table_name }} + + {% endfor %} +
    + {% else %} +

    Aucun objet pour l'instant.

    + {% endif %} + +
    + +
    +

    🧩 Éléments de jeu {{ element_types|length }}

    + {% if element_types %} + + {% else %} +

    Aucun élément de jeu pour l'instant.

    + {% endif %} + +
    + +
    +

    🔧 Variables {{ variables|length }}

    + {% if variables %} +
    + {% for v in variables %} +
    + {{ v.name }} + {{ v.value or '' }} +
    + {% endfor %} +
    + {% else %} +

    Aucune variable pour l'instant.

    + {% endif %} +
    + +
    +

    Paramètres du jeu

    +
    + +
    + + +
    +
    +
    + +
    +
    {% endblock %} diff --git a/templates/game_variables.html b/templates/game_variables.html index 5af74bcf..ff0e0994 100644 --- a/templates/game_variables.html +++ b/templates/game_variables.html @@ -2,10 +2,6 @@ {% block title %}Variables — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • Variables
  • -{% endblock %} {% block content %}

    Variables globales

    Des propriétés/valeurs communes à tout le jeu (pas attachées à un objet de données précis) — utile par exemple pour une porte ouverte ou non, un niveau débloqué... Une variable peut être lue dans une "Condition de visibilité" (propriétés d'un élément) et modifiée par une action "Modifier une variable" (Logique de la scène).

    diff --git a/templates/index.html b/templates/index.html index 0f1b71fa..0beacafb 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,6 +1,5 @@ {% extends "base.html" %} {% block title %}Forge Engine — Mes jeux{% endblock %} -{% block breadcrumb_wrap %}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} {% block content %} diff --git a/templates/object_edit.html b/templates/object_edit.html index 8f6db622..a7cd4718 100644 --- a/templates/object_edit.html +++ b/templates/object_edit.html @@ -2,11 +2,6 @@ {% block title %}Modifier {{ definition.name }} — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • {{ definition.name }}
  • -
  • Modifier
  • -{% endblock %} {% block content %}

    Modifier l'objet « {{ definition.name }} »

    Table SQL : {{ definition.table_name }}

    diff --git a/templates/object_form.html b/templates/object_form.html index 4d0fa670..4569e23f 100644 --- a/templates/object_form.html +++ b/templates/object_form.html @@ -2,10 +2,6 @@ {% block title %}Nouvel objet — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -
  • {{ game.name }}
  • -
  • Nouvel objet
  • -{% endblock %} {% block content %}

    Définir un nouvel objet

    diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 45a873e0..13dd33e0 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -6,10 +6,6 @@ {% endblock %} - -{% block breadcrumb_wrap %}{% endblock %} {% block content %} {% if screen.is_template %}

    diff --git a/templates/screens_list.html b/templates/screens_list.html index ad429be1..2224d52e 100644 --- a/templates/screens_list.html +++ b/templates/screens_list.html @@ -2,10 +2,6 @@ {% block title %}Écrans — {{ game.name }}{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} -{% block breadcrumb %} -

  • {{ game.name }}
  • -
  • Écrans
  • -{% endblock %} {% block content %}

    Écrans du jeu

    {% if request.args.get('blocked_screen') %} -- 2.54.0 From 65450a5719d2a9b69c0ebd3d4635d592208311e5 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 10:15:37 +0200 Subject: [PATCH 14/24] =?UTF-8?q?Refait=20le=20dashboard=20en=20fen=C3=AAt?= =?UTF-8?q?re=20=C3=A0=20onglets=20avec=20de=20vrais=20tableaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/games/game_dashboard.py | 1 + static/style.css | 26 +-- templates/game_dashboard.html | 280 ++++++++++++++++++++++++--------- 3 files changed, 216 insertions(+), 91 deletions(-) diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index de93aaee..4cee98ad 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -24,4 +24,5 @@ def game_dashboard(slug): "game_dashboard.html", game=game, definitions=definitions, screen_list=screen_list, element_types=element_types, element_type_usage=element_type_usage, variables=variables, + variable_types=db.GLOBAL_VARIABLE_TYPES, ) diff --git a/static/style.css b/static/style.css index 23dd0df4..b92f902f 100644 --- a/static/style.css +++ b/static/style.css @@ -494,16 +494,16 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p .animKeyframeRow textarea{ font-family:monospace; font-size:11px; } /* ---------- Tableau de bord d'un jeu (game_dashboard.html) ---------- */ -/* Résumé/point d'entrée : une carte par catégorie (écrans, objets, - éléments de jeu, variables), toutes de même hauteur dans leur ligne - (align-items:stretch, par défaut en grid) pour un rendu net même quand - une carte a beaucoup plus d'entrées qu'une autre. */ -.dashboardGrid{ display:grid; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:16px; margin-top:8px; } -.dashboardCard{ display:flex; flex-direction:column; border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); } -.dashboardCard h2{ display:flex; align-items:center; justify-content:space-between; margin:0 0 10px; } -.dashboardCardCount{ font-size:11px; color:var(--text-dim); background:var(--panel2); border-radius:999px; padding:2px 8px; text-transform:none; letter-spacing:0; } -.dashboardCardList{ display:flex; flex-direction:column; gap:2px; flex:1 1 auto; min-height:0; } -.dashboardCardItem{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 8px; border-radius:6px; text-decoration:none; color:var(--text); font-size:12.5px; } -a.dashboardCardItem:hover{ background:var(--panel2); } -.dashboardCardItem span:first-child{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.dashboardCardFooter{ margin-top:10px; padding-top:10px; border-top:1px solid var(--border); font-size:12.5px; } +/* Une seule fenêtre, un onglet par catégorie (écrans/objets/éléments de + jeu/variables) — réutilise .builderTabs/.builderTabBtn/.builderTabPanel + (voir plus bas, éditeur d'écran) pour la même sensation de "fenêtre à + onglets" qu'ailleurs dans l'outil, plutôt que d'inventer un 2e système. + Contrairement à leur usage dans l'éditeur d'écran, ici la page défile + normalement (pas de hauteur figée) : une liste peut être longue, pas + besoin d'un défilement interne séparé par panneau. */ +.dashTabCount{ font-size:10px; color:var(--text-dim); background:var(--panel2); border-radius:999px; padding:1px 6px; margin-left:4px; } +.dashPanelHeader{ display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; } +.dashPanelHeader h2{ margin:0; } +.dashCreateBar{ display:flex; gap:8px; align-items:center; margin-bottom:12px; padding:10px; border:1px solid var(--border); border-radius:8px; background:var(--panel); } +.dashCreateBar input, .dashCreateBar select{ width:auto; flex:1 1 160px; } +.dashDangerZone{ margin-top:28px; } diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index aae0ef67..4627b2dc 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -3,87 +3,192 @@ {% block content_class %}content-wide{% endblock %} {% block content %}

    {{ game.name }}

    -

    Base de données : projects/{{ game.slug }}/game.db

    +

    Base de données : projects/{{ game.slug }}/game.db

    -
    -
    -

    🖥️ Écrans du jeu {{ screen_list|length }}

    - {% if screen_list %} -
    - {% for s in screen_list %} - - {{ s.name }} - {% if loop.first %}★ Départ{% endif %} - - {% endfor %} -
    - {% else %} -

    Aucun écran pour l'instant.

    - {% endif %} - -
    - -
    -

    🗂️ Objets {{ definitions|length }}

    - {% if definitions %} -
    - {% for d in definitions %} - - {{ d.name }} - {{ d.table_name }} - - {% endfor %} -
    - {% else %} -

    Aucun objet pour l'instant.

    - {% endif %} - -
    - -
    -

    🧩 Éléments de jeu {{ element_types|length }}

    - {% if element_types %} - - {% else %} -

    Aucun élément de jeu pour l'instant.

    - {% endif %} - -
    - -
    -

    🔧 Variables {{ variables|length }}

    - {% if variables %} -
    - {% for v in variables %} -
    - {{ v.name }} - {{ v.value or '' }} -
    - {% endfor %} -
    - {% else %} -

    Aucune variable pour l'instant.

    - {% endif %} - -
    + +
    + + + +
    -
    + +
    +
    +

    Écrans du jeu

    + +
    + + {% if screen_list %} +
    + + + + {% for s in screen_list %} + + + + + + {% endfor %} + +
    #Nom
    {{ loop.index }} + {{ s.name }} + {% if loop.first %} ★ Départ{% endif %} + +
    +
    + ✏️ +
    + +
    +
    +
    + {% else %} +

    Aucun écran pour l'instant.

    + {% endif %} +
    + + +
    +
    +

    Objets

    + + Nouvel objet +
    +

    Un objet a des champs typés (nombre, texte, relation...) — sa création se fait sur sa propre page.

    + {% if definitions %} +
    + + + + {% for d in definitions %} + + + + + + {% endfor %} + +
    NomTable SQL
    {{ d.name }}{{ d.table_name }} + ✏️ +
    +
    + {% else %} +

    Aucun objet pour l'instant.

    + {% endif %} +
    + + +
    +
    +

    Éléments de jeu

    + +
    + + {% if element_types %} +
    + + + + {% for t in element_types %} + + + + + + {% endfor %} + +
    NomUsage
    {{ t.icon or '🧩' }} {{ t.name }} + {{ element_type_usage.get(t.id, 0) }} instance(s) + {% if t.bound_definition_id %} + {% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %} + {% endif %} + + ✏️ +
    + +
    +
    +
    + {% else %} +

    Aucun élément de jeu pour l'instant.

    + {% endif %} +
    + + +
    +
    +

    Variables

    + +
    + + {% if variables %} + {% for v in variables %} +
    + {% endfor %} +
    + + + + {% for v in variables %} + + + + + + + {% endfor %} + +
    NomTypeValeur
    {{ v.name }} + + + +
    + +
    +
    +
    + {% else %} +

    Aucune variable pour l'instant.

    + {% endif %} +
    + +

    Paramètres du jeu

    @@ -97,4 +202,23 @@
    + + {% endblock %} -- 2.54.0 From d875557254512b43a0bb6cb96e758db3aea85715 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 10:54:19 +0200 Subject: [PATCH 15/24] =?UTF-8?q?Fusionne=20les=204=20pages=20de=20cr?= =?UTF-8?q?=C3=A9ation=20dans=20le=20dashboard,=20retire=20le=20titre/BDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/element_types/element_type_delete.py | 2 +- routes/element_types/element_type_edit.py | 2 +- routes/element_types/element_types.py | 19 ++- routes/games/game_dashboard.py | 2 +- routes/global_vars/__init__.py | 1 - routes/global_vars/create_global_var.py | 2 +- routes/global_vars/game_variables.py | 19 --- routes/global_vars/global_var_delete.py | 2 +- routes/global_vars/global_var_edit.py | 2 +- routes/objects/object_new.py | 18 +-- routes/screens/__init__.py | 2 +- routes/screens/screen_delete.py | 4 +- routes/screens/screen_move.py | 2 +- routes/screens/screens_list.py | 12 -- routes/screens/screens_new.py | 2 +- static/style.css | 1 + templates/base.html | 24 ++-- templates/element_types.html | 69 --------- templates/game_dashboard.html | 152 +++++++++++++++++++- templates/game_variables.html | 71 --------- templates/object_form.html | 100 ------------- templates/screen_edit.html | 10 +- templates/screens_list.html | 58 -------- tests/test_screens_and_elements.py | 12 +- 24 files changed, 197 insertions(+), 391 deletions(-) delete mode 100644 routes/global_vars/game_variables.py delete mode 100644 routes/screens/screens_list.py delete mode 100644 templates/element_types.html delete mode 100644 templates/game_variables.html delete mode 100644 templates/object_form.html delete mode 100644 templates/screens_list.html diff --git a/routes/element_types/element_type_delete.py b/routes/element_types/element_type_delete.py index 7b268ba3..1172c0bf 100644 --- a/routes/element_types/element_type_delete.py +++ b/routes/element_types/element_type_delete.py @@ -9,4 +9,4 @@ from core.flask_app import app def element_type_delete(slug, element_type_id): if screens.element_type_usage_count(slug, element_type_id) == 0: screens.delete_element_type(slug, element_type_id) - return redirect(url_for("element_types", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_type_edit.py b/routes/element_types/element_type_edit.py index a1ce7b90..75660849 100644 --- a/routes/element_types/element_type_edit.py +++ b/routes/element_types/element_type_edit.py @@ -13,4 +13,4 @@ def element_type_edit(slug, element_type_id): name = request.form.get("name", "").strip() or None bound_definition_id = request.form.get("bound_definition_id") or None screens.update_element_type(slug, element_type_id, name=name, bound_definition_id=bound_definition_id) - return redirect(url_for("element_types", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/element_types/element_types.py b/routes/element_types/element_types.py index f00a3c88..f4f9fe6d 100644 --- a/routes/element_types/element_types.py +++ b/routes/element_types/element_types.py @@ -1,6 +1,5 @@ -from flask import render_template, request, redirect, url_for +from flask import request, redirect, url_for -import db import screens from core.flask_app import app @@ -8,18 +7,16 @@ from core.flask_app import app @app.route("/game//element-types", methods=["GET", "POST"]) def element_types(slug): - game = db.game_meta(slug) + """La création/gestion d'un élément de jeu se fait désormais depuis + l'onglet "Éléments de jeu" du tableau de bord (voir game_dashboard.html) + plutôt qu'une page dédiée — cette route ne fait donc plus que traiter la + création (barre de création repliable de cet onglet). Un GET direct + redirige vers le tableau de bord.""" if request.method == "POST": name = request.form.get("name", "").strip() icon = request.form.get("icon", "").strip() or "🧩" bound_definition_id = request.form.get("bound_definition_id") or None if name: screens.create_element_type(slug, name, icon, bound_definition_id) - return redirect(url_for("element_types", slug=slug)) - types = screens.list_element_types(slug) - usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in types} - definitions = db.list_definitions(slug) - return render_template( - "element_types.html", game=game, types=types, usage=usage, widgets=screens.WIDGETS, - definitions=definitions, - ) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) + return redirect(url_for("game_dashboard", slug=slug, tab="element_types")) diff --git a/routes/games/game_dashboard.py b/routes/games/game_dashboard.py index 4cee98ad..886b08a5 100644 --- a/routes/games/game_dashboard.py +++ b/routes/games/game_dashboard.py @@ -24,5 +24,5 @@ def game_dashboard(slug): "game_dashboard.html", game=game, definitions=definitions, screen_list=screen_list, element_types=element_types, element_type_usage=element_type_usage, variables=variables, - variable_types=db.GLOBAL_VARIABLE_TYPES, + variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES, ) diff --git a/routes/global_vars/__init__.py b/routes/global_vars/__init__.py index e076e9b1..0da1055e 100644 --- a/routes/global_vars/__init__.py +++ b/routes/global_vars/__init__.py @@ -1,4 +1,3 @@ from .create_global_var import create_global_var -from .game_variables import game_variables from .global_var_edit import global_var_edit from .global_var_delete import global_var_delete diff --git a/routes/global_vars/create_global_var.py b/routes/global_vars/create_global_var.py index 38e88827..4f50a41f 100644 --- a/routes/global_vars/create_global_var.py +++ b/routes/global_vars/create_global_var.py @@ -15,4 +15,4 @@ def create_global_var(slug): default_value = request.form.get("default_value") or "" if name: db.create_global_variable(slug, name, var_type, default_value) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/global_vars/game_variables.py b/routes/global_vars/game_variables.py deleted file mode 100644 index 80ae9ff0..00000000 --- a/routes/global_vars/game_variables.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask import render_template - -import db - -from core.flask_app import app - - -@app.route("/game//variables") -def game_variables(slug): - """Page de gestion des variables globales (voir db/global_vars/) : un - objet "jeu" implicite dont les propriétés/valeurs sont visibles partout - (condition de visibilité, action "Modifier une variable"), plutôt - qu'attachées à un objet de données précis.""" - game = db.game_meta(slug) - variables = db.list_global_variables(slug) - return render_template( - "game_variables.html", game=game, variables=variables, - variable_types=db.GLOBAL_VARIABLE_TYPES, - ) diff --git a/routes/global_vars/global_var_delete.py b/routes/global_vars/global_var_delete.py index 4aa8920d..9a89f507 100644 --- a/routes/global_vars/global_var_delete.py +++ b/routes/global_vars/global_var_delete.py @@ -8,4 +8,4 @@ from core.flask_app import app @app.route("/game//variables//delete", methods=["POST"]) def global_var_delete(slug, var_id): db.delete_global_variable_by_id(slug, var_id) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/global_vars/global_var_edit.py b/routes/global_vars/global_var_edit.py index d09ef66f..edf1cd63 100644 --- a/routes/global_vars/global_var_edit.py +++ b/routes/global_vars/global_var_edit.py @@ -10,4 +10,4 @@ def global_var_edit(slug, var_id): var_type = request.form.get("type") or "texte" value = request.form.get("value") or "" db.update_global_variable(slug, var_id, var_type, value) - return redirect(url_for("game_variables", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="variables")) diff --git a/routes/objects/object_new.py b/routes/objects/object_new.py index 6ee7fd29..2c23524a 100644 --- a/routes/objects/object_new.py +++ b/routes/objects/object_new.py @@ -1,4 +1,4 @@ -from flask import render_template, request, redirect, url_for +from flask import request, redirect, url_for import db @@ -8,20 +8,16 @@ from .parse_field_rows import _parse_field_rows @app.route("/game//objects/new", methods=["GET", "POST"]) def object_new(slug): - game = db.game_meta(slug) - existing_definitions = db.list_definitions(slug) - + """La création d'un objet se fait désormais depuis un panneau déplaçable + sur le tableau de bord du jeu (voir game_dashboard.html, formulaire + #objectForm) plutôt qu'une page dédiée — cette route ne fait donc plus + que traiter la soumission de CE formulaire. Un GET direct (ex. lien + externe obsolète) redirige simplement vers le tableau de bord.""" if request.method == "POST": name = request.form.get("object_name", "").strip() fields = _parse_field_rows(request.form) - if name and fields: definition_id = db.create_definition(slug, name, fields) return redirect(url_for("object_view", slug=slug, definition_id=definition_id)) - return render_template( - "object_form.html", - game=game, - existing_definitions=existing_definitions, - field_types=db.FIELD_TYPES, - ) + return redirect(url_for("game_dashboard", slug=slug)) diff --git a/routes/screens/__init__.py b/routes/screens/__init__.py index 5c8c5f25..adfe0646 100644 --- a/routes/screens/__init__.py +++ b/routes/screens/__init__.py @@ -1 +1 @@ -from . import screens_list, screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect +from . import screens_new, screen_rename, screen_move, screen_delete, screen_edit, screen_set_aspect diff --git a/routes/screens/screen_delete.py b/routes/screens/screen_delete.py index 880a5d32..e4c421db 100644 --- a/routes/screens/screen_delete.py +++ b/routes/screens/screen_delete.py @@ -9,6 +9,6 @@ from core.flask_app import app def screen_delete(slug, screen_id): referencing = screens.screens_referencing(slug, screen_id) if referencing: - return redirect(url_for("screens_list", slug=slug, blocked_screen=screen_id)) + return redirect(url_for("game_dashboard", slug=slug, tab="screens", blocked_screen=screen_id)) screens.delete_screen(slug, screen_id) - return redirect(url_for("screens_list", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/routes/screens/screen_move.py b/routes/screens/screen_move.py index 8a9d920c..24e3b8d2 100644 --- a/routes/screens/screen_move.py +++ b/routes/screens/screen_move.py @@ -9,4 +9,4 @@ from core.flask_app import app def screen_move(slug, screen_id): direction = -1 if request.form.get("direction") == "up" else 1 screens.move_screen(slug, screen_id, direction) - return redirect(url_for("screens_list", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/routes/screens/screens_list.py b/routes/screens/screens_list.py deleted file mode 100644 index c3e2b093..00000000 --- a/routes/screens/screens_list.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask import render_template - -import db -import screens - -from core.flask_app import app - - -@app.route("/game//screens") -def screens_list(slug): - game = db.game_meta(slug) - return render_template("screens_list.html", game=game, screen_list=screens.list_screens(slug)) diff --git a/routes/screens/screens_new.py b/routes/screens/screens_new.py index 4eda95dc..7566471f 100644 --- a/routes/screens/screens_new.py +++ b/routes/screens/screens_new.py @@ -11,4 +11,4 @@ def screens_new(slug): if name: screen_id = screens.create_screen(slug, name) return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id)) - return redirect(url_for("screens_list", slug=slug)) + return redirect(url_for("game_dashboard", slug=slug, tab="screens")) diff --git a/static/style.css b/static/style.css index b92f902f..80dbdd33 100644 --- a/static/style.css +++ b/static/style.css @@ -192,6 +192,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d .floatPanel.hidden{ display:none; } .floatPanel--left{ left:24px; width:280px; } .floatPanel--right{ right:24px; width:340px; } +.floatPanel--center{ left:50%; width:560px; max-width:calc(100vw - 48px); transform:translateX(-50%); } .floatPanelHeader{ display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; border-bottom:1px solid var(--border); background:var(--panel2); diff --git a/templates/base.html b/templates/base.html index b5f0602f..b73a183c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,21 +15,17 @@
    {% if game is defined and game %} - + {% endif %} diff --git a/templates/element_types.html b/templates/element_types.html deleted file mode 100644 index 6cdf30e2..00000000 --- a/templates/element_types.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% block title %}Éléments de jeu — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block content %} -

    Éléments de jeu

    -

    Un élément de jeu est un conteneur réutilisable.

    - -
    -
    -

    Créer

    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -
    -
    - -
    -

    Catalogue

    - {% if types %} -
    - - - - {% for t in types %} - - - - - - {% endfor %} - -
    NomUsage
    {{ t.icon }} {{ t.name }} - {{ usage.get(t.id, 0) }} instance(s) - {% if t.bound_definition_id %} - {% for d in definitions %}{% if d.id == t.bound_definition_id %} · « {{ d.name }} »{% endif %}{% endfor %} - {% endif %} - - ✏️ -
    - -
    -
    -
    - {% else %} -

    Aucun élément de jeu défini pour l'instant.

    - {% endif %} -
    -
    -{% endblock %} diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index 4627b2dc..ee9ed1f5 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -2,8 +2,6 @@ {% block title %}{{ game.name }} — Forge Engine{% endblock %} {% block content_class %}content-wide{% endblock %} {% block content %} -

    {{ game.name }}

    -

    Base de données : projects/{{ game.slug }}/game.db

    + {% if request.args.get('blocked_screen') %} +
    + ⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action. +
    + {% endif %}

    Écrans du jeu

    @@ -63,9 +66,8 @@

    Objets

    - + Nouvel objet +
    -

    Un objet a des champs typés (nombre, texte, relation...) — sa création se fait sur sa propre page.

    {% if definitions %}
    @@ -203,6 +205,104 @@ + + + + + + + {% endblock %} diff --git a/templates/game_variables.html b/templates/game_variables.html deleted file mode 100644 index ff0e0994..00000000 --- a/templates/game_variables.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% block title %}Variables — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block content %} -

    Variables globales

    -

    Des propriétés/valeurs communes à tout le jeu (pas attachées à un objet de données précis) — utile par exemple pour une porte ouverte ou non, un niveau débloqué... Une variable peut être lue dans une "Condition de visibilité" (propriétés d'un élément) et modifiée par une action "Modifier une variable" (Logique de la scène).

    - -
    -
    -

    Créer

    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    - -
    -

    Variables définies

    - {% if variables %} - {% for v in variables %} -
    - {% endfor %} -
    -
    - - - {% for v in variables %} - - - - - - - {% endfor %} - -
    NomTypeValeur
    {{ v.name }} - - - -
    - -
    -
    -
    - {% else %} -

    Aucune variable pour l'instant — crée la première à gauche.

    - {% endif %} -
    -
    -{% endblock %} diff --git a/templates/object_form.html b/templates/object_form.html deleted file mode 100644 index 4569e23f..00000000 --- a/templates/object_form.html +++ /dev/null @@ -1,100 +0,0 @@ -{% extends "base.html" %} -{% block title %}Nouvel objet — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block content %} -

    Définir un nouvel objet

    - -
    -
    -
    -

    Objet

    -
    -
    - - -
    -
    -
    -
    - -
    -

    Champs

    - -
    -
    -
    -
    -
    -
    - - - - -{% endblock %} diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 13dd33e0..9632209a 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -141,7 +141,7 @@ {% endfor %}
    {% else %} -

    Pas encore d'élément de jeu personnalisé — crées-en un.

    +

    Pas encore d'élément de jeu personnalisé — crées-en un.

    {% endif %}
    @@ -220,7 +220,7 @@ {% if not global_variables %} -

    Aucune variable globale pour l'instant — crées-en une ↗

    +

    Aucune variable globale pour l'instant — crées-en une ↗

    {% endif %}
    @@ -808,7 +808,7 @@
    {% if c.key == 'visibilite_cond_variable' %} -

    Pas la variable qu'il te faut ? Gérer les variables du jeu ↗

    +

    Pas la variable qu'il te faut ? Gérer les variables du jeu ↗

    {% endif %} {% endif %} {% endif %} diff --git a/templates/screens_list.html b/templates/screens_list.html deleted file mode 100644 index 2224d52e..00000000 --- a/templates/screens_list.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% block title %}Écrans — {{ game.name }}{% endblock %} -{% block content_class %}content-wide content-objectEdit{% endblock %} -{% block body_class %}objectEditBody{% endblock %} -{% block content %} -

    Écrans du jeu

    -{% if request.args.get('blocked_screen') %} -
    - ⚠️ Suppression impossible : au moins un autre écran a un bouton qui cible précisément celui-ci. Retire d'abord cette action. -
    -{% endif %} - -
    -
    -

    Créer

    -
    -
    - - -
    -
    -
    -
    - -
    -

    Enchaînement des écrans

    - {% if screen_list %} -
    - - - - {% for s in screen_list %} - - - - - - {% endfor %} - -
    #Nom
    {{ loop.index }} - {{ s.name }} - {% if loop.first %} ★ Départ{% endif %} - -
    -
    - ✏️ -
    - -
    -
    -
    - {% else %} -

    Aucun écran pour l'instant — crée le premier à gauche.

    - {% endif %} -
    -
    -{% endblock %} diff --git a/tests/test_screens_and_elements.py b/tests/test_screens_and_elements.py index e61e4816..42b463fd 100644 --- a/tests/test_screens_and_elements.py +++ b/tests/test_screens_and_elements.py @@ -100,15 +100,17 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object): follow_redirects=False, ) assert resp.status_code == 302 - html = client.get(f"/game/{slug}/element-types").data.decode() + html = client.get(f"/game/{slug}").data.decode() m = re.search(r"/screens/(\d+)/edit", html) assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue" template_screen_id = int(m.group(1)) # L'écran-modèle est un écran caché (is_template) : absent de la liste - # normale des écrans jouables. - screens_html = client.get(f"/game/{slug}/screens").data.decode() - assert "CarteMail" not in screens_html or "Modèle" not in screens_html + # normale des écrans jouables (onglet "Écrans du jeu" du tableau de + # bord) — son nom complet ("Modèle : CarteMail") ne doit donc apparaître + # nulle part sur cette page, contrairement à "CarteMail" seul (le nom de + # l'élément de jeu lui-même, affiché dans l'onglet "Éléments de jeu"). + assert "Modèle : CarteMail" not in html # Construit le contenu du modèle : un titre lié au champ "sujet". resp = client.post( @@ -266,7 +268,7 @@ def test_screen_editor_property_panels_closed_by_default(client, game): def test_delete_element_type_removes_hidden_template_screen(client, game): resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False) - html = client.get(f"/game/{game}/element-types").data.decode() + html = client.get(f"/game/{game}").data.decode() m = re.search(r"/screens/(\d+)/edit", html) template_screen_id = int(m.group(1)) element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1)) -- 2.54.0 From e1216e99d5fd281e04d64e314cf7595db7bbd586 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 11:04:13 +0200 Subject: [PATCH 16/24] =?UTF-8?q?Rend=20le=20panneau=20"Nouvel=20objet"=20?= =?UTF-8?q?compact=20=E2=80=94=20lignes=20de=20tableau,=20pas=20des=20cart?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/object_form.js | 24 +++++-- templates/game_dashboard.html | 121 +++++++++++++++------------------- 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/static/object_form.js b/static/object_form.js index c0202878..5125678f 100644 --- a/static/object_form.js +++ b/static/object_form.js @@ -17,12 +17,28 @@ var NUMERIC_FIELD_TYPES = ['nombre_entier', 'nombre_decimal']; function wireRow(row) { + // Ligne de TABLEAU dense (une ligne = un champ, comme "Champs + // existants" dans object_edit.html) : "Objet lié" et "Mini"/"Maxi" + // gardent toujours leur colonne (jamais masquée, sinon les colonnes + // des différentes lignes ne s'alignent plus) — seul leur CONTENU + // bascule entre le vrai champ de saisie et un espace réservé "—" + // quand il ne s'applique pas au type choisi. var typeSelect = row.querySelector('.fieldTypeSelect'); - var relationWrap = row.querySelector('.relationTarget'); - var boundsWrap = row.querySelector('.boundsTarget'); + var relationTarget = row.querySelector('.relationTarget'); + var relationPlaceholder = row.querySelector('.relationPlaceholder'); + var boundsTargets = row.querySelectorAll('.boundsTarget'); + var boundsPlaceholders = row.querySelectorAll('.boundsPlaceholder'); + + function toggle(target, placeholder, visible) { + target.style.display = visible ? '' : 'none'; + placeholder.style.display = visible ? 'none' : ''; + } + function syncFieldTypeVisibility() { - relationWrap.style.display = typeSelect.value === 'relation' ? 'block' : 'none'; - if (boundsWrap) boundsWrap.style.display = NUMERIC_FIELD_TYPES.includes(typeSelect.value) ? 'flex' : 'none'; + var isRelation = typeSelect.value === 'relation'; + var isNumeric = NUMERIC_FIELD_TYPES.includes(typeSelect.value); + toggle(relationTarget, relationPlaceholder, isRelation); + boundsTargets.forEach(function (el, i) { toggle(el, boundsPlaceholders[i], isNumeric); }); } typeSelect.addEventListener('change', syncFieldTypeVisibility); syncFieldTypeVisibility(); diff --git a/templates/game_dashboard.html b/templates/game_dashboard.html index ee9ed1f5..5feb20a3 100644 --- a/templates/game_dashboard.html +++ b/templates/game_dashboard.html @@ -213,8 +213,11 @@ position persistée en localStorage ici, contrairement aux panneaux de l'éditeur d'écran — inutile pour un panneau ouvert ponctuellement). Contenu repris de l'ancienne page "Définir un nouvel objet" - (object_form.html, retirée) : même formulaire, même gabarit de ligne - de champ, même script (object_form.js). --> + (object_form.html, retirée), mais avec un gabarit de ligne dense — + une vraie ligne de tableau (comme "Champs existants" dans + object_edit.html) plutôt qu'une grosse carte par champ — pensé pour + ajouter beaucoup de champs d'affilée sans que le panneau explose en + hauteur. --> -- 2.54.0 From b8dfdcb20577d7fe0ca28667e4b613c6fa10d744 Mon Sep 17 00:00:00 2001 From: william Date: Thu, 27 Aug 2026 11:56:49 +0200 Subject: [PATCH 17/24] =?UTF-8?q?Corrige=20la=20nav=20pjax,=20panneau=20d?= =?UTF-8?q?=C3=A9pla=C3=A7able/redimensionnable=20pour=20=C3=A9diter=20un?= =?UTF-8?q?=20objet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Bug pjax trouvé : swapDocument() cherchait "header.topbar", qui n'a jamais existé (c'est un