Le nom affiché dans la barre de navigation (base.html) devient un lien
vers /profile : informations du compte (email en lecture seule, nom/
prénom modifiables), changement de mot de passe (mot de passe actuel
requis + mêmes règles de force qu'à l'inscription/la réinitialisation),
et une section "danger" pour demander la suppression du compte.
La suppression exige le mot de passe actuel ET la saisie exacte de
"SUPPRIMER" (deux confirmations distinctes pour une action irréversible)
— routes/auth/profile.py. Pour un compte "user" (limité à un seul projet,
créé automatiquement et impossible à supprimer autrement, voir
core/auth_guard.py), supprimer le compte supprime aussi son unique projet
sur le disque, faute de quoi il resterait orphelin sans plus aucun
propriétaire. Un compte "admin" peut posséder plusieurs jeux qui ne lui
sont pas dédiés de la même façon : ses projets ne sont jamais touchés.
L'unique compte administrateur ne peut pas être supprimé (auth/
count_admins.py) — le supprimer bloquerait la création d'un nouveau
compte admin (réservée au tout premier compte jamais créé, base vide).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Un lien "Mot de passe oublié ?" (login.html) mène à /forgot-password :
si l'adresse saisie correspond à un compte, un jeton à haute entropie
(secrets.token_urlsafe, valable 1h) est généré et envoyé par email via
smtplib (auth/send_email.py, aucune dépendance ajoutée) — configuré
uniquement par variables d'environnement (SMTP_HOST/PORT/USER/PASSWORD/
FROM, voir .env.example et docker-compose.prod.yml), n'importe quel
serveur SMTP existant convient (Mailcow compris).
Seul le hash SHA-256 du jeton est stocké (auth/password_reset.py, table
_password_reset_tokens) : un jeton envoyé par email reste inutilisable
même en cas de fuite de la base. Le même message générique s'affiche que
l'adresse corresponde à un compte ou non, pour ne jamais permettre à ce
formulaire de servir à deviner quelles adresses sont déjà inscrites. Un
échec d'envoi (SMTP non configuré) est journalisé côté serveur seulement,
jamais révélé à l'utilisateur.
/reset-password/<token> vérifie le jeton (non expiré, non déjà utilisé),
applique les mêmes règles de mot de passe fort qu'à l'inscription (même
schéma visuel), puis consomme le jeton et remet à zéro le compteur
anti-bruteforce du compte (auth/set_password.py) — une identité prouvée
par email est une voie de récupération légitime même pour un compte
verrouillé.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 codes à usage unique (format "xxxx-xxxx-xxxx") sont générés à
l'instant même où la 2FA est confirmée (auth/recovery_codes.py, table
_recovery_codes séparée pour marquer/consommer chaque code un par un) —
seul leur hash (werkzeug, comme les mots de passe) est stocké, ils ne
sont visibles en clair qu'à cet instant précis.
Plutôt que d'interrompre la redirection habituelle après confirmation de
la 2FA, les codes sont posés en session ("recovery_codes_to_show") et
affichés une seule fois, en modal, dès le premier rendu de base.html qui
suit (core/recovery_codes_flash.py, session.pop) — préserve tel quel le
comportement de redirection déjà couvert par les tests existants.
Sur /login/2fa, un code de récupération est accepté à la place du code
TOTP habituel (routes/auth/login_2fa.py) : verify_totp est essayé en
premier (verify_recovery_code consomme le code dès qu'il correspond, on
ne veut pas en griller un pour rien sur une saisie qui aurait en fait
été un TOTP valide). Compte toujours vers le même compteur anti-bruteforce
que le code TOTP (déjà en place, voir auth/rate_limit.py).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Un jeton unique par session (core/csrf.py, exposé côté Jinja via
csrf_token()) est vérifié sur toute requête non-GET par un before_request
(core/csrf_guard.py), dans le même esprit que core/auth_guard.py : une
seule garde globale plutôt que de toucher aux ~90 routes existantes une
par une.
L'app entière fait déjà transiter ses formulaires par fetch() : pjax.js
intercepte chaque <form> interne et le transforme lui-même en requête
fetch (aucun usage de l'attribut d'échappement data-no-pjax nulle part
dans le repo, confirmé par grep). Il suffit donc de patcher window.fetch
UNE SEULE FOIS (static/csrf_fetch.js) pour y ajouter automatiquement
l'en-tête X-CSRFToken sur toute requête non-GET, formulaires pjax comme
fetch() écrits à la main dans screen_edit.html/game_dashboard.html/
play.html — sans modifier un seul appel existant.
La vérification est désactivée quand app.config["TESTING"] est actif
(même convention que Flask-WTF/WTF_CSRF_ENABLED), pour ne pas avoir à
ajouter le jeton aux ~170 tests existants qui appellent les routes
directement via le client de test Flask. tests/test_csrf.py réactive
volontairement la garde pour la mettre à l'épreuve pour de vrai (GET
jamais bloqué, POST sans jeton/avec mauvais jeton -> 400, POST avec le
bon jeton via l'en-tête ou le champ de formulaire -> succès).
templates/play.html reçoit les mêmes deux balises que base.html car il
est autonome (ne l'étend pas, propre <html>/<head>).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Première des deux grandes fonctionnalités demandées (authentification
d'abord, export HTML/CSS/JS autonome ensuite) :
- Inscription (nom, prénom, email UNIQUE, mot de passe) avec schéma
visuel du mot de passe (jauge + liste de critères qui passent au vert
en direct — auth/password_strength.py, mêmes règles vérifiées côté
serveur qu'affichées côté client).
- Double authentification (TOTP, compatible Google Authenticator/Authy)
OBLIGATOIRE dès l'inscription : QR code (SVG, sans dépendance Pillow)
à scanner puis code à confirmer avant que le compte soit utilisable —
voir auth/create_user.py (totp_confirmed) et routes/auth/register_2fa.py.
- Connexion en 2 temps (mot de passe puis code TOTP), déconnexion.
- Isolation par utilisateur : un compte "user" est limité à un SEUL
projet, dont le dossier est nommé d'après son adresse email (slugifiée)
et créé automatiquement dès la 2FA confirmée — aucune page de gestion
multi-jeux pour lui (redirigé directement vers son propre tableau de
bord). Le rôle "admin" reste illimité, comme le moteur l'a toujours été
(le TOUT PREMIER compte jamais créé sur une base de comptes vide devient
automatiquement admin — voir auth/is_first_user.py — pas de mot de
passe par défaut à faire circuler : s'inscrire en premier suffit).
Un compte "user" ne peut pas non plus supprimer son unique projet
(aucune façon d'en recréer un ensuite).
- Garde d'accès globale (core/auth_guard.py, un seul before_request) :
toute page exige une connexion, sans avoir touché individuellement aux
~80 routes déjà existantes du moteur.
tests/conftest.py isole complètement les tests de la vraie base de
comptes (FORGE_USERS_DB_PATH/FORGE_SECRET_KEY_PATH vers un dossier
temporaire propre à la session de tests) et authentifie automatiquement
la fixture `client` partagée en tant que compte admin de test — les 155
tests déjà existants continuent de passer SANS AUCUNE modification de
leur côté, exactement comme avant l'authentification. 10 nouveaux tests
dédiés (tests/test_auth.py) : inscription/mots de passe/2FA/connexion/
isolation par projet/blocage de suppression, vérifiés en conditions
réelles (vrai client de test Flask, vraie base SQLite, vrais codes TOTP
calculés avec pyotp). 165 tests au total, tous au vert.
Nouvelles dépendances : pyotp, qrcode (requirements.txt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Suite du correctif précédent (124f250, marqueur "dataBound") : le
rafraîchissement fonctionnait, mais remplaçait tout l'élément de PREMIER
NIVEAU marqué — pour un Texte "Donnée liée" posé DANS un élément de jeu
réutilisable (ex. une boîte de dialogue), le seul élément de premier
niveau existant est l'EXEMPLAIRE lui-même (ses descendants internes ne
sont jamais des entrées séparées de la scène, voir list_elements.py) :
tout l'exemplaire — voile plein écran, boîte, tout — était donc remplacé
d'un bloc, ce qui le faisait visuellement disparaître puis réapparaître
pour un simple changement de texte à l'intérieur.
Correctif (templates/play.html, refreshRuntimeData()) : avant de
remplacer un élément marqué en bloc, on cherche d'abord, DANS le nouveau
fragment, des descendants plus précis portant eux-mêmes un marqueur en
commentaire ("visibilityGated"/"dataBound" — jamais "repeaterItem"/
"jaugeBar", qui restent volontairement régénérés en bloc, un
comportement déjà correct pour un Répéteur/une Jauge). S'il en existe,
seuls CES éléments précis sont patchés individuellement (retrouvés via
leur propre data-element-id) ; sinon, comportement inchangé (remplace
l'élément entier, cas normal d'un Texte "Donnée liée" posé directement
sur une scène, hors élément de jeu réutilisable).
155 tests toujours au vert (changement purement côté client — le
rendu serveur et la présence du marqueur, eux, étaient déjà couverts par
les tests du commit précédent). Vérifié structurellement (adjacence
</p><!--dataBound--> confirmée dans le rendu réel du projet "test").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bug rapporté : changer une variable globale utilisée comme valeur de
comparaison d'une "Donnée liée" (Texte/Titre, dans une boîte de dialogue
posée comme élément de jeu réutilisable) recalculait bien, côté SERVEUR,
la bonne ligne à afficher — mais le contenu affiché en jeu restait figé
sur son ancienne ligne tant que la page n'était pas complètement
rechargée.
Cause : templates/play.html ne régénère, après une action "Modifier une
variable"/"Modifier une donnée", QUE les éléments dont le rendered_html
porte un marqueur connu (voir refreshRuntimeData()/hasMarker() —
"repeaterItem", "jaugeBar", "visibilityGated"). Un Texte/Titre "Donnée
liée" n'en portait AUCUN : jamais identifié comme "dépendant de la
donnée", donc jamais régénéré, même si le nouveau HTML était déjà prêt
côté serveur à chaque rendu.
Correctif : nouveau marqueur "dataBound" (render_element_html.py), posé
dès que _data_definition_id est réglé, reconnu par hasMarker(). Un
exemplaire d'élément de jeu (ex. la boîte de dialogue posée sur une
scène) porte ce marqueur EN PROFONDEUR dans son propre rendered_html dès
qu'un de ses descendants internes en a un — il se retrouve donc bien
régénéré dans son ensemble, sans changement supplémentaire nécessaire.
Deux nouveaux tests, confirmés en échec sur l'ancien code (même scénario
que le rapport : reproduit avec objet "dialog" + variable "dialog_order"
+ élément de jeu réutilisable) puis au vert avec le correctif. 155 tests
au vert au total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deux fonctionnalités demandées, développées et corrigées dans cet
échange :
1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
screens/clause_list_codec.py). Rétrocompatible avec les anciens
éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
volée à la lecture, sans migration. Après un premier essai à la
présentation trop compacte et technique (retour utilisateur : "pas de
champ technique, pas de notation bizarre {{ }}"), la présentation
finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
"...cette valeur", même sélecteur de valeur fixe/dynamique/variable
déjà existant, jamais la syntaxe brute), simplement répétée par
condition (templates/partials/clause_row.html), avec un bouton
"+ Ajouter une condition" bien visible et une liste scrollable
(static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
rows.py généralisé) profite aussi au Répéteur de données en interne.
2. Nœud Condition de la Logique de la scène : peut désormais tester une
VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
CLIENT (templates/play.html, evaluateConditionClause), contre un
nouveau gameData.variables exposé par full_game_payload.py — tenu à
jour par refreshRuntimeData() après toute action qui modifie une
variable, sans changement supplémentaire nécessaire. Le panneau de
condition reste utilisable même sans aucun objet défini dans le jeu
(avant, il disparaissait entièrement).
Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bug rapporté : modifier une donnée dans l'onglet "Données" d'un objet
redirigeait vers le panneau d'un AUTRE objet (le premier de la liste) et
n'enregistrait rien sur le bon objet.
Cause : chaque objet a sa propre table SQLite (db/rows/insert_row.py), donc
les ids de ses lignes repartent de 1 - deux objets ont chacun une ligne
#1, #2, etc. Or game_dashboard.html générait les formulaires d'édition/
suppression d'une ligne avec un id DOM basé seulement sur r.id
("dataEditForm{{r.id}}"), jamais sur l'objet auquel elle appartient. Les
<input form="dataEditForm1"> de DEUX objets différents pointaient donc
vers le même id de formulaire dupliqué dans le document - et un id HTML
dupliqué se résout vers le PREMIER élément trouvé (le premier objet listé),
pas celui réellement affiché sous les yeux de l'utilisateur.
Correctif : les ids de formulaires ("dataEditForm"/"dataDeleteForm") et les
attributs form="..." des champs sont maintenant scopés par objet ET par
ligne ("dataEditForm{{d.id}}-{{r.id}}"), comme c'était déjà le cas pour les
panneaux (objectEditPanel, addEntryPanel...) via data-definition-id.
Nouveau test (tests/test_data_form_id_collision.py) : reproduit le
scénario exact (deux objets ayant chacun une ligne #1) et vérifie que les
ids de formulaire sont bien distincts et que la modification du second
objet ne touche pas le premier - confirmé en échec sur l'ancien code
(git stash) puis au vert avec le correctif. 128 tests au vert au total.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deux retours utilisateur sur le panneau "Configurer la valeur" ajouté au
commit précédent :
- le bouton "+ Ajouter" (style is-outlined) se voyait mal dans le panneau
sombre -> repassé en bouton "primary" pleine largeur, et déplacé sous la
liste des lignes (là où on l'attend pour ajouter une ligne de plus,
plutôt qu'au-dessus, hors du flux de lecture) ;
- le bouton "OK - utiliser cette valeur" était un aller-retour inutile :
supprimé. Chaque changement (texte tapé, clé, type, ajout ou retrait
d'une ligne) réécrit désormais tout de suite le JSON dans le champ
cible via saveVarValuePanel(), écoutée sur les événements input/change
du conteneur des lignes - exactement comme si on tapait directement
dans le champ, sans étape de validation séparée.
Aucun changement Python (127 tests toujours au vert) ; vérifié la syntaxe
du <script> rendu de l'onglet Variables via node --check.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L'édition en JSON brut (textarea) n'était pas adaptée à un public non
développeur. Remplacée par un panneau "Configurer la valeur" (déplaçable/
redimensionnable/plein écran, comme les autres) avec une ligne par clé
(Objet) ou par élément (Tableau) : un champ Clé (Objet seulement), un
type (Texte/Nombre/Oui-Non) et une valeur en texte libre — le JSON est
reconstruit automatiquement au clic sur "OK", jamais tapé à la main.
Un seul panneau partagé (#varValuePanel) pour toutes les variables : à
l'ouverture (openVarValuePanel(targetInputId, mode)), reconstruit une
ligne par entrée déjà présente dans le JSON actuel de l'<input> ciblé —
un objet/tableau imbriqué en valeur est préservé tel quel (JSON.stringify
en texte opaque) plutôt que perdu, même si cette valeur précise n'est pas
éditable via une ligne (portée volontairement limitée aux valeurs
simples : suffisant pour une configuration ou une liste, sans la
complexité d'un éditeur JSON récursif).
- Barre "+ Créer une variable" : le champ de valeur devient en lecture
seule pour Objet/Tableau, avec un bouton "🔧 Configurer" à côté.
- Ligne d'une variable existante : la cellule Valeur montre un aperçu en
lecture seule + le même bouton "🔧 Configurer" (au lieu du textarea
JSON du commit précédent).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deux nouveaux types de variable globale (db/constants.py) : "objet" et
"tableau" — valeur stockée en JSON (colonne TEXT existante), avec
validation à la création/modification (db/global_vars/
coerce_structured_value.py) : un JSON invalide retombe sur un défaut sûr
("{}"/"[]") plutôt que de corrompre silencieusement la variable pour
toutes ses lectures suivantes. apply_variable_action.py n'a besoin
d'aucun changement — "definir_texte" écrit déjà n'importe quelle chaîne
telle quelle.
Nouvelle résolution de chemin, partagée (screens/rendering/
filter_repeater_rows.py::_resolve_variable_path) : navigue dans la
valeur JSON d'une variable selon un chemin ".champ"/"[index]" chaînable
(ex. ".arme.degats", "[0].valeur") — ne lève jamais, renvoie None si le
JSON est invalide ou qu'un segment du chemin ne correspond à rien.
Branchée à deux endroits, qui lisaient déjà une variable globale :
- La valeur de comparaison {{$nom_variable}} (filtres de Répéteur ET
Condition de visibilité, qui partagent le même
_resolve_filter_value()) accepte maintenant un chemin optionnel :
{{$perso.nom}}, {{$scores[0]}}. Le sélecteur "Variable globale" du
panneau de propriétés (screen_edit.html, .filterValueVariable) gagne un
champ "Chemin optionnel" à côté du choix de variable — même regex
étendue côté JS (_VAR_REF_RE) que côté Python (_VAR_REF_PATTERN), pour
que la valeur round-trip correctement à la réouverture du panneau.
- La variable VÉRIFIÉE par une Condition de visibilité en mode "variable"
(choisie via un <select>, pas la syntaxe {{$...}}) gagne son propre
nouveau contrôle "Chemin dans la variable" (visibility_condition_
controls.py) — nécessaire pour comparer un champ d'un Objet ou un
élément d'un Tableau, pas seulement la variable entière.
game_dashboard.html (onglet Variables) : la valeur par défaut de la barre
de création devient un <textarea> (fonctionne aussi bien pour un JSON
multi-ligne qu'un scalaire court), et la cellule "Valeur" du tableau des
variables existantes devient un <textarea> quand le type est objet/
tableau.
4 nouveaux tests (tests/test_variable_object_array.py) : validation JSON
à la création, filtre de Répéteur avec chemin chaîné, condition de
visibilité avec accès par index de tableau, chemin invalide/absent sans
plantage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Un objet avec beaucoup de champs rendait le tableau "Données
enregistrées" illisible (toutes les colonnes serrées sur une ligne,
valeurs tronquées) — deux ajouts pour compenser :
1) "🔧 Colonnes" : menu déroulant (une case à cocher par champ, toutes
cochées par défaut) au-dessus du tableau — décocher un champ masque sa
colonne (th + td, via [data-col]) sans reconstruire le tableau. Un seul
menu ouvert à la fois, fermé au clic ailleurs sur la page.
2) 👁️ par ligne : ouvre un panneau de détail (déplaçable/redimensionnable/
plein écran par défaut, comme les autres) listant TOUS les champs de
cette entrée, un par ligne, valeur complète non tronquée — reprend le
style .rowDetailField/.rowDetailLabel/.rowDetailValue de l'ancienne
modale Bulma de data_list.html (retirée, mais ce CSS ne dépendait pas du
template et a été gardé). Un seul panneau PAR OBJET (pas par ligne) :
son contenu est reconstruit à l'ouverture en lisant en direct la ligne
du tableau déjà affiché (via [data-row-id] sur chaque <tr>) — reflète
donc aussi une modification tout juste saisie mais pas encore
"Enregistrer"ée, sans aller-retour serveur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chaque panneau (Nouvel objet, Modifier un objet, Ajouter un champ,
Ajouter une entrée) s'ouvre désormais en plein écran par défaut (marge
de 12px) plutôt qu'en petite fenêtre centrée — plus confortable dès
qu'il y a plusieurs champs/entrées à voir en même temps. Un bouton ⛶
dans l'en-tête bascule vers/depuis la taille et la position précédentes
(mémorisées le temps de la session, pas persistées), pour qui préfère
un panneau plus petit à côté du reste du tableau de bord.
makeFloatPanelFullscreenable(panel, toggleBtn) — générique, posée sur
panel._fsToggle — appelée par les 4 familles de panneaux existantes.
makeFloatPanelDraggable()/makeFloatPanelResizable() sortent d'abord
proprement du plein écran si l'utilisateur interagit manuellement
(glisser l'en-tête ou tirer le coin) : sans ça, bottom/right encore
actifs en plein écran auraient repris la main sur la position/taille
qu'on vient de poser.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
La barre en ligne (tous les champs alignés horizontalement) devenait
illisible au-delà d'une poignée de champs — un objet à 10 champs
donnait une ligne de saisie qui débordait largement de l'écran.
Remplacée par un simple bouton "+ Ajouter" qui ouvre un panneau dédié
(déplaçable et redimensionnable, comme les autres) avec un formulaire
VERTICAL — un champ par ligne, étiquette au-dessus, comme un formulaire
normal — plutôt qu'entassé sur une seule ligne. Un panneau "Ajouter un
champ" et un panneau "Ajouter une entrée" par objet, tous deux cachés
par défaut.
Réutilise makeFloatPanelDraggable()/makeFloatPanelResizable() (déjà
génériques, voir commit précédent) — aucun nouveau mécanisme de
glisser-déposer à écrire.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Suite de la demande : le panneau "Modifier un objet" (crayon ✏️, onglet
Objets du tableau de bord) a maintenant 2 sous-onglets — "Champs" (déjà
en place) et "Données", qui reprend data_list.html + data_form.html
(retirés) : ajouter une entrée (une ligne compacte avec le bon type de
champ par colonne — texte/nombre/case à cocher/date/relation, comme
l'ancien formulaire), modifier/supprimer une entrée existante (tableau
dense, cellules éditables en ligne, même principe que "Champs
existants").
Sous-onglets scopés au panneau de LEUR objet (switchObjectPanelTab(),
classes .objectPanelTabs/.objectPanelTabPanel distinctes de .builderTabs/
.builderTabPanel) — plusieurs objets ont chacun leurs propres
sous-onglets indépendants sur la même page, sans jamais interférer avec
les onglets du tableau de bord lui-même.
routes/games/game_dashboard.py fournit maintenant, par objet : ses lignes
(rows_by_definition), les libellés lisibles de ses champs relation
(relation_labels_by_definition, pour l'affichage) et leurs options
(relation_options_by_definition, pour les <select>), ainsi que
referenced_by_definition (avertissement permanent si un autre objet a
une relation vers celui-ci — repris de l'ancien object_view.py, affiché
maintenant en continu plutôt qu'après une tentative de suppression
échouée).
data_form.py (partagé par data_new/data_edit), data_delete.py et
object_delete.py redirigent maintenant vers le tableau de bord
(?edit=<id>&subtab=data, +?blocked_row=<id> si la suppression d'une
entrée est bloquée par une relation) au lieu de object_view/object_edit.
Piège évité : de nombreux tests déduisent l'id d'un objet fraîchement
créé du DERNIER SEGMENT du chemin dans le header Location d'une
redirection (.../objects/<id>) — rediriger object_new directement vers
le tableau de bord (chemin sans id) cassait donc 33 tests d'un coup.
Fix : object_view.py reste en place, mais seulement comme redirecteur
(plus de page rendue) — object_new redirige toujours vers lui (chemin
qui se termine par l'id, donc les tests continuent de fonctionner), qui
redirige à son tour vers le tableau de bord.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1) Bug pjax trouvé : swapDocument() cherchait "header.topbar", qui n'a
jamais existé (c'est un <nav>, pas un <header>) — la barre de navigation
du jeu (ajoutée après pjax.js) n'était donc jamais mise à jour pendant
une navigation pjax : absente en arrivant sur un jeu sans Ctrl+F5, et
inversement laissée en place en revenant sur l'accueil où elle n'a rien
à faire. Fix : tout ce qui est hors <main> mais doit changer d'une page
à l'autre (topbar + barre du jeu) est regroupé dans un nouveau conteneur
stable #pageChrome, que pjax.js remplace en bloc — plus fiable qu'un
sélecteur qui ne correspondait à rien. CSS (flex:0 0 auto des layouts
plein-écran) mis à jour en conséquence.
2) Renommages demandés : onglet/panneau "Écrans du jeu" -> "Écrants",
"Éléments de jeu" -> "Templates" (tab, titre de panneau, bouton
"+ Créer un template", état vide, infobulle, confirmation de
suppression).
3) Le crayon ✏️ sur une ligne d'objet ouvre désormais un panneau
déplaçable ET redimensionnable (nouveau coin de redimensionnement
générique, .floatPanelResizeHandle) au lieu de naviguer vers
object_edit.html (retirée) — un panneau par objet, pré-rendu et
caché par défaut. Reprend telles quelles les fonctionnalités de
l'ancienne page : renommer l'objet, ajouter un champ (ligne compacte),
modifier/supprimer un champ existant (tableau dense déjà repris pour
"Nouvel objet"), supprimer l'objet. Les routes de champs (object_field_
add/edit/delete) et object_edit lui-même redirigent maintenant vers le
tableau de bord avec ?edit=<id>, pour rouvrir automatiquement le bon
panneau après l'action plutôt que de le fermer silencieusement.
makeFloatPanelDraggable()/makeFloatPanelResizable() généralisées pour
être partagées entre "Nouvel objet" et les panneaux d'édition, plutôt
que du code dupliqué par panneau.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chaque champ prenait une grosse carte (~250px de haut : libellés Bulma
pleine taille, "Retirer ce champ" en texte) — inutilisable pour un objet
à 10+ champs, ce qui est pourtant l'usage visé par ce panneau.
Remplacé par une vraie ligne de tableau dense, sur le même principe que
"Champs existants" dans object_edit.html (déjà compact et sobre dans le
reste de l'outil) : une ligne = un champ, colonnes Nom/Type/Objet
lié/Mini/Maxi/Obligatoire, action "Retirer" réduite à une icône. Les
colonnes conditionnelles (Objet lié pour une relation, Mini/Maxi pour un
nombre) restent TOUJOURS présentes — sans quoi les colonnes de lignes
différentes ne s'aligneraient plus — seul leur contenu bascule entre le
vrai champ de saisie et un espace réservé "—", au lieu de masquer toute
la cellule comme avant.
object_form.js adapté en conséquence : bounds désormais 2 cibles
séparées (mini/maxi, chacune dans sa propre cellule) au lieu d'une seule
enveloppe commune, et chaque bascule s'accompagne de celle de son
espace réservé.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Suite de la demande précédente : les 4 pages autrefois listées dans la
barre de navigation n'existent plus en tant que pages séparées — tout
vit désormais dans les onglets du tableau de bord (commit précédent) ou,
pour la création d'un objet, dans un panneau déplaçable.
- screens_list.html + sa route (screens_list) : supprimés (l'onglet
"Écrans du jeu" du dashboard couvre déjà tout : créer, réordonner,
éditer, supprimer). screen_new/screen_move/screen_delete redirigent
maintenant vers le dashboard (tab=screens) au lieu de cette page.
- element_types.html : supprimé, mais la route element_types est
conservée (GET redirige vers le dashboard, POST — utilisé par la barre
de création repliable de l'onglet "Éléments de jeu" — continue de
fonctionner). element_type_edit/element_type_delete redirigent aussi
vers le dashboard.
- game_variables.html + sa route (game_variables) : supprimés (l'onglet
"Variables" du dashboard couvre déjà tout). create_global_var/
global_var_edit/global_var_delete redirigent vers le dashboard
(tab=variables) au lieu de cette page.
- object_form.html : supprimé. La route object_new (POST) est conservée
pour traiter la soumission du panneau — voir plus bas — mais ne rend
plus de page pour un GET (redirige vers le dashboard).
Nouveau panneau déplaçable "Nouvel objet" sur le dashboard (bouton
"+ Nouvel objet" de l'onglet Objets) : réutilise .floatPanel/
.floatPanelHeader/.floatPanelBody (déjà utilisées dans l'éditeur d'écran)
avec une nouvelle variante centrée (.floatPanel--center) et son propre
glisser-déposer minimal (pas de position persistée, contrairement aux
panneaux de l'éditeur d'écran — inutile pour un panneau ouvert
ponctuellement). Contenu et script (object_form.js) repris tels quels de
l'ancienne page.
base.html : la barre de navigation du jeu n'a donc plus que 2 liens —
"📊 Tableau de bord" (nouveau) et "▶️ Jouer" (toujours en dernier).
game_dashboard.html : titre du jeu et chemin de la base de données
retirés (redondants avec le nom déjà visible dans l'onglet du
navigateur/la barre de nav).
Les liens "crée-en un"/"gérer les variables" dans l'éditeur d'écran
(screen_edit.html) pointent maintenant vers le dashboard avec le bon
onglet (?tab=...), lu et appliqué au chargement de la page
(switchDashTab() côté JS).
2 tests (test_screens_and_elements.py) mis à jour : ils vérifiaient le
contenu des pages supprimées (element-types, screens) — adaptés pour
vérifier la même chose sur le dashboard, qui porte maintenant cette
information.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le précédent dashboard (grille de cartes compactes) ne correspondait pas
à ce que l'utilisateur voulait : une seule fenêtre avec de vrais tableaux
de données (denses, colonnes nettes), un bouton "Créer" par catégorie
dans l'en-tête, et une navigation horizontale pour passer d'une catégorie
à l'autre.
Réutilise telles quelles .builderTabs/.builderTabBtn/.builderTabPanel
(déjà utilisées pour "Écran / Logique / Timeline" dans l'éditeur d'écran)
plutôt que d'inventer un 2e système d'onglets — même sensation partout
dans l'outil. Un onglet par catégorie (Écrans/Objets/Éléments de
jeu/Variables), chacun avec :
- un bouton "+ Créer" dans l'en-tête qui révèle une barre de création
compacte (repliée par défaut) — sauf pour les Objets, dont la
création (plusieurs champs typés) reste sur sa propre page dédiée,
trop complexe pour tenir dans une barre ;
- le VRAI tableau de gestion de cette catégorie (colonnes, actions),
repris tel quel de screens_list.html/element_types.html/
game_variables.html plutôt que réinventé en version appauvrie.
La page défile désormais normalement (retrait de body.objectEditBody/
content-objectEdit, pensés pour une hauteur figée avec défilement
interne) — une liste peut être longue, pas besoin d'un défilement séparé
par panneau ici.
routes/games/game_dashboard.py fournit en plus variable_types
(db.GLOBAL_VARIABLE_TYPES) pour la barre de création de variable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trois demandes distinctes de l'utilisateur, regroupées car elles touchent
toutes à la navigation d'un jeu :
1. Fil d'Ariane retiré (devenu redondant avec la barre de navigation du
jeu ajoutée au commit précédent) : bloc breadcrumb_wrap retiré de
base.html, et son override ({% block breadcrumb %}) retiré des 9
templates qui le définissaient encore. .breadcrumbBar (CSS) retiré,
y compris des règles flex:0 0 auto de body.objectEditBody/builderBody.
2. Liens de .gameNavBar centrés (justify-content:center).
3. "Jouer" déplacé en dernier lien (c'est une action à part — ouvre
l'aperçu jouable dans un nouvel onglet — pas un éditeur de plus comme
les 4 autres).
4. game_dashboard.html devient un vrai tableau de bord : une grille de
cartes (Écrans/Objets/Éléments de jeu/Variables), chacune listant les
entrées existantes avec un accès direct (clic = éditeur concerné) et
un lien "Gérer" vers la page dédiée pour créer/réorganiser. Remplace
l'ancien panneau "Créer" (redondant avec la barre de navigation
persistante) et la simple table "Objets définis". routes/games/
game_dashboard.py alimente maintenant aussi screen_list, element_types
(+ usage) et variables, en réutilisant list_screens/
list_element_types/element_type_usage_count/list_global_variables déjà
utilisés ailleurs.
Vérifié en rendant toutes les pages concernées via le client de test
Flask : barre de nav présente partout où un `game` est dans le contexte
(absente sur l'accueil), fil d'Ariane absent partout, ordre des liens
avec "Jouer" en dernier.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Jusqu'ici, les liens vers les différents éditeurs d'un jeu (Écrans,
Éléments de jeu, Variables, Jouer, Objet) n'existaient que dans le
panneau "Créer" du tableau de bord (game_dashboard.html) — changer
d'éditeur obligeait à revenir sur cette page à chaque fois.
base.html expose désormais une 2e barre (.gameNavBar), sous la barre
"Forge Engine" et au-dessus du fil d'Ariane, reprenant ces mêmes 5
liens — visible sur TOUTE page qui met un `game` dans le contexte du
template (déjà fait par chaque route pour le fil d'Ariane, donc aucun
changement de route nécessaire), absente sur l'accueil (liste des jeux,
pas de jeu courant). L'onglet correspondant à la section actuelle est
mis en évidence via un simple préfixe sur request.path.
static/style.css : nouvelle barre en ligne (contrairement à .navList/
.navRow, empilés verticalement dans le panneau "Créer" du tableau de
bord, réutilisés tels quels ailleurs). Ajoutée aux règles flex:0 0 auto
de body.objectEditBody/body.builderBody (mise en page plein-écran des
éditeurs) aux côtés de .topbar/.breadcrumbBar, sans quoi elle aurait
cassé la répartition de hauteur figée de ces pages.
Vérifié en rendant plusieurs routes via le client de test Flask : barre
absente sur l'accueil, présente partout ailleurs (tableau de bord, liste
des écrans, éditeur d'écran normal ET d'écran-modèle, éléments de jeu,
variables, nouvel objet).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ces onglets ("Logique de la scène", "Timeline d'animation") étaient
masqués sur l'écran-modèle d'un élément de jeu réutilisable, avec un
commentaire expliquant pourquoi : à l'époque, ses enfants étaient COPIÉS
en base avec un NOUVEL id à chaque exemplaire posé
(instantiate_template_tree) — une logique/animation posée dans le
modèle référencerait donc des ids qui n'existent plus une fois
l'élément utilisé ailleurs.
Ce mécanisme a depuis été retiré (voir le commentaire dans
list_elements.py) : le contenu d'un élément de jeu réutilisable est
désormais TOUJOURS rechargé EN DIRECT depuis son écran-modèle à chaque
affichage, avec les MÊMES ids à chaque exemplaire. Combiné au commit
précédent (findTriggerNode/runFlowFrom/collectAnimationClips côté
play.html, qui exécutent maintenant correctement un déclencheur/une
animation posé dans un modèle, où qu'il soit utilisé), la restriction
de cette page n'avait donc plus lieu d'être — elle bloquait justement la
fonctionnalité que le commit précédent venait de rendre possible.
Les données nécessaires (flow_nodes/flow_edges/elements du modèle,
etc.) étaient déjà calculées sans condition par la route
(routes/screens/screen_edit.py) ; seul le template masquait les deux
onglets et leur contenu derrière {% if not screen.is_template %}.
switchBuilderTab() détecte déjà la présence des panneaux via
HAS_FLOW_PANEL/HAS_ANIM_PANEL (document.getElementById), donc aucun
changement JS n'était nécessaire.
Vérifié en rendant réellement /game/test/screens/3/edit (l'écran-modèle
"mail content") via le client de test Flask : les deux onglets sont
maintenant bien présents, et l'écran normal (id=1) n'est pas affecté.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Jusqu'ici, la logique (déclencheurs Au clic/Au survol/Fin du survol) et
les animations posées dans l'éditeur de l'écran-MODÈLE d'un élément de
jeu réutilisable (ex. "mail content") sur SES PROPRES enfants ne
s'exécutaient jamais quand cet élément était simplement posé sur une
autre scène : findTriggerNode() cherchait bien le déclencheur dans tous
les écrans (modèles compris) mais runFlowFrom() n'exécutait ensuite le
graphe que dans l'écran RÉELLEMENT affiché — le nœud trouvé n'existait
pas dans ce graphe-là, donc rien ne se déclenchait, silencieusement.
Même limitation pour les animations, dont la timeline ne lisait que les
clips propres à l'écran affiché.
Logique (templates/play.html) :
- findTriggerNode() renvoie désormais { node, screenId } plutôt que
juste le nœud, pour transmettre l'écran D'ORIGINE du déclencheur (qui
peut être un écran-modèle).
- runFlowFrom(nodeId, flowScreenId) accepte un 2e paramètre optionnel
(par défaut l'écran affiché, comportement inchangé pour tout le
reste) pour exécuter le graphe dans le BON écran.
- bindClicks()/bindHoverTriggers() passent maintenant cet écran
d'origine à runFlowFrom(). runScreenShowTriggers() (déclencheur "À
l'affichage de l'écran") reste volontairement inchangé — hors scope,
ambiguïté sur plusieurs exemplaires d'un même modèle sur un écran.
Animations (screens/payload/full_game_payload.py, templates/play.html) :
- Le payload expose désormais element_types (element_type_id -> id de
son écran-modèle), via screens.list_element_types() déjà existant.
- collectAnimationClips(screenId) rassemble récursivement les clips de
l'écran affiché ET de tout écran-modèle utilisé par un de ses
éléments (garde anti-boucle, dédoublonnage par écran).
- applyAnimationClip() cible désormais TOUS les exemplaires d'un id
d'élément (querySelectorAll, plus querySelector) : un enfant de
modèle garde le même id à chaque exemplaire, y compris pour chaque
ligne d'un Répéteur utilisant ce modèle comme gabarit de ligne.
Limite connue, non corrigée ici (pas la demande) : une action "Modifier
un élément" ciblant un enfant de modèle reste, elle, scopée au premier
exemplaire trouvé dans le DOM (document.querySelector singulier dans
runActionNode/applyElementProperty) — sans impact pour un modèle posé
une seule fois par écran, comme dans le cas d'usage actuel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
L'utilisateur a raison de pointer que le vrai souci n'était pas un bug
isolé mais l'APPROCHE elle-même : detruire puis reconstruire un nœud du
DOM à chaque clic (même bien ciblé, comme depuis les 2 derniers commits)
cause toujours un flash visuel, puisque tout état transitoire du
sous-arbre (visibilité posée par "Modifier un élément", focus...) est
perdu et reconstruit à neuf. C'est ce qui donnait l'impression trompeuse
d'un "rechargement" — un comportement JS parfaitement normal quand on
manipule le DOM ainsi, mais évitable : c'est exactement le problème que
la réconciliation ciblée de React (ne patcher que ce qui a changé,
jamais recréer un nœud pour rien) résout côté framework.
applyOpenRowBindings() ne remplace donc plus JAMAIS le nœud de l'élément
ciblé (ex. "mail content") — il patche directement, en place :
- un nœud TEXTE contenant {{champ}} est coupé en 3 (texte avant, un
<span data-bind-field="champ">, texte après) LA PREMIÈRE FOIS
SEULEMENT ; toute ouverture suivante se contente de changer le
textContent de ce span — plus aucune reconstruction ensuite.
- un ATTRIBUT contenant {{champ}} (ex. href="{{link_real_url}}") voit
son gabarit d'origine mémorisé sur data-bind-attr-<nom> au premier
passage, pour être recalculé et réécrit directement à chaque fois
sans jamais reconstruire le nœud.
Plus aucun nœud n'étant détruit, la sauvegarde/restauration de l'état
visuel transitoire (ajoutée dans un commit précédent pour compenser
cette destruction) devient inutile et est retirée.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Les deux commits précédents (ciblage des éléments imbriqués dans
applyOpenRowBindings() et refreshRuntimeData()) n'avaient AUCUN effet
visible, confirmé par l'utilisateur après redémarrage du serveur — cause
trouvée : leur filtre "ne garder que les éléments les plus spécifiques"
vérifiait l'inverse de ce qu'il fallait.
Un CONTENEUR contient toujours le HTML de ses descendants dans son
propre rendered_html — donc un ancêtre "a le marqueur/placeholder" quasi
systématiquement dès qu'un descendant l'a. Le filtre précédent excluait
un élément candidat si un de ses ANCÊTRES était candidat — ce qui, vu ce
qui précède, ne gardait quasiment jamais que l'ancêtre RACINE de
l'écran, reproduisant exactement le bug d'origine (tout l'écran
régénéré) que ces commits visaient à corriger.
Fix : inversion du sens du filtre — un candidat est désormais exclu si
l'un de ses PROPRES DESCENDANTS est aussi candidat (le descendant sera
déjà régénéré individuellement, inutile de régénérer aussi son
ancêtre). Vérifié par une simulation Node.js reproduisant la structure
réelle de l'écran de test (Répéteur niché sous 2 conteneurs, "mail
content" sous 2 autres) : la nouvelle logique cible bien uniquement le
Répéteur et "mail content", plus jamais le conteneur racine de l'écran.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Même défaut que celui corrigé dans applyOpenRowBindings() (commit
précédent), mais dans le second endroit qui régénère l'écran après un
changement de donnée : refreshRuntimeData() ne pouvait régénérer que les
éléments de PREMIER NIVEAU (seuls eux ont un data-el-id sur leur wrapper
.playElement). Un Répéteur niché dans un conteneur — comme celui de cet
écran — n'est jamais du premier niveau : c'est donc son ANCÊTRE de
premier niveau qui portait le marqueur "repeaterItem" à l'intérieur et
se faisait régénérer en entier à sa place, potentiellement l'écran
complet (jauges, onglets compris) si l'écran n'a qu'un seul gros
conteneur racine. C'était la cause réelle du "rechargement" toujours
visible après le précédent correctif : celui-ci ne portait que sur
applyOpenRowBindings(), pas sur cette 2e régénération déclenchée par
"Modifier une donnée"/"Modifier une variable".
Fix : même principe que le commit précédent — cible chaque élément
marqué (repeaterItem/jaugeBar/visibilityGated) directement via son
data-element-id, à n'importe quel niveau d'imbrication, en ne gardant
que les plus "hauts" parmi les éléments marqués pour ne jamais régénérer
un même nœud deux fois.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cause du "rechargement" perçu par l'utilisateur (et du flash à vide sur
la ligne de Répéteur juste cliquée, visible sur une vidéo de repro) :
applyOpenRowBindings() ne pouvait cibler que les éléments de PREMIER
NIVEAU de l'écran (seuls eux ont un wrapper .playElement dans le DOM).
Sur cet écran, "mail content" est niché à 2 conteneurs de profondeur, et
le SEUL élément de premier niveau est le conteneur racine de tout
l'écran — donc chaque clic sur une ligne de Répéteur régénérait
littéralement tout l'écran (jauges, onglets, Répéteur compris) pour ne
mettre à jour qu'un seul panneau de détail, avec un flash à vide pendant
la reconstruction.
Fix : applyOpenRowBindings() cible maintenant directement, à n'importe
quel niveau d'imbrication, le(s) élément(s) qui portent réellement un
{{champ}} non résolu (repéré via document.querySelector
('[data-element-id=...]'), disponible sur CHAQUE élément rendu, pas
seulement les élément de premier niveau) — et seulement les plus "hauts"
parmi eux, pour ne jamais régénérer un même nœud deux fois. Seul "mail
content" est donc désormais remplacé (via replaceWith), sans toucher au
Répéteur ni au reste de l'écran. La sauvegarde/restauration de l'état
visuel transitoire (style, classes, dataset hors clickBound/hoverBound/
hoverTriggerBound) suit le même principe, appliquée au nœud remplacé et
à ses descendants.
Aucun aller-retour réseau n'a jamais eu lieu ici (refreshRuntimeData()
utilise déjà fetch/JSON, pas de navigation de page) — la sensation de
rechargement venait uniquement de la granularité du remplacement DOM,
pas d'un manque d'AJAX.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause enfin identifiée grâce à un log console instrumenté par
l'utilisateur (indispensable — sans lui les précédents correctifs
visaient le mauvais chemin de code, refreshRuntimeData(), qui ne se
déclenchait même pas dans ce scénario) : l'action "Ouvrir la ligne
cliquée" (ouvrir_ligne) appelle applyOpenRowBindings() DIRECTEMENT,
sans jamais rappeler bindClicks() ensuite — contrairement à
refreshRuntimeData(), qui elle le fait déjà correctement.
Si l'écran a un Répéteur ET un panneau de détail (avec des {{champ}})
posés dans un même conteneur parent, applyOpenRowBindings() régénère
tout ce sous-arbre — Répéteur compris — pour résoudre les {{champ}} du
panneau. Les lignes du Répéteur héritent alors de nœuds DOM tout neufs,
sans le moindre écouteur de clic (le garde-fou anti-doublon de
bindClicks() repose sur dataset.clickBound, absent sur un nœud neuf,
mais bindClicks() lui-même n'était jamais rappelé pour les attacher).
Symptôme exact reproduit : le tout premier clic sur une ligne fonctionne
(gestionnaires posés au chargement de la page), plus AUCUN clic ne
répond ensuite sur AUCUNE ligne, sans erreur console — confirmé par un
log montrant runFlowFrom() jamais réinvoqué au clic suivant, et
manuellement réparé en rappelant bindClicks() à la main dans la
console.
Fix : bindClicks()/bindHoverTexts()/bindHoverTriggers() sont maintenant
rappelés juste après applyOpenRowBindings() dans le gestionnaire de
"Ouvrir la ligne cliquée", comme ils le sont déjà dans
refreshRuntimeData().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Test de diagnostic déterminant : après le blocage rapporté (plus aucune
ligne de Répéteur ne répond après le tout premier clic), appeler
manuellement bindClicks() dans la console suffisait à tout réparer —
donc ni le DOM ni le graphe de logique n'étaient en cause, seule
l'INVOCATION de bindClicks() manquait à un moment donné.
Cause : refreshRuntimeData() faisait un retour anticipé silencieux
(`if (!screenData || !screenDiv) return;`) qui sautait, avec lui,
TOUT le reste de la fonction — y compris bindClicks(), bindHoverTexts()
et bindHoverTriggers() — sans le moindre message d'erreur, laissant les
éléments régénérés (Répéteur compris) sans aucun écouteur pour le reste
de la partie.
Fix : ce garde-fou ne protège plus désormais que le bloc de régénération
du contenu de l'écran (qui a effectivement besoin de screenData/
screenDiv) ; les réattachements, eux, s'exécutent toujours ensuite, quoi
qu'il arrive. Un try/catch autour de la régénération ajoute en prime un
filet de sécurité : toute erreur inattendue s'y loggera clairement au
lieu de bloquer silencieusement le reste, si jamais ce n'était pas
l'unique cause.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Régression introduite par le commit précédent (préservation de l'état
visuel transitoire dans applyOpenRowBindings) : la restauration du
dataset complet d'un nœud copiait aussi clickBound/hoverBound/
hoverTriggerBound — des indicateurs INTERNES au moteur (voir bindClicks/
bindHoverTexts/bindHoverTriggers), jamais un état posé par une action
"Modifier un élément". Un nœud tout juste régénéré se retrouvait donc
marqué "déjà lié" à tort, alors qu'aucun écouteur de clic n'y était
réellement rattaché : bindClicks() le voyait déjà "bound" et sautait
son rattachement, rendant l'élément silencieusement inerte pour le
reste de la partie.
Symptôme rapporté : dans un écran avec un Répéteur ET un panneau de
détail utilisant des {{champ}}, le premier clic sur une ligne fonctionne
(exécuté par les gestionnaires posés au chargement de la page), mais
plus aucun clic ne répond ensuite sur AUCUNE ligne — le Répéteur étant
regénéré dans le même sous-arbre que le panneau de détail (ancêtre
commun avec des {{champ}} non résolus), donc concerné par la même
restauration de dataset.
Fix : exclure ces trois clés internes de la sauvegarde/restauration —
seul l'état réellement transitoire (style inline, classes, data-toggle-*
posés par "Modifier un élément") doit survivre à la regénération.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cause réelle du bug rapporté ("clic sur une ligne = comme un rechargement,
impossible de cliquer sur une deuxième ligne") : applyOpenRowBindings()
regénère entièrement le sous-arbre d'un élément contenant des {{champ}}
(ex. "mail content") à partir de son HTML D'ORIGINE, tel que rendu par le
serveur — donc avec son style PAR DÉFAUT (ici "invisible" via le réglage
Disposition > Visibilité). Cette fonction est appelée après CHAQUE
rafraîchissement de données (refreshRuntimeData), y compris pour un
changement de donnée sans rapport avec ce panneau.
Or une action "Modifier un élément → Rendre visible" ne modifie JAMAIS la
base : c'est un changement DOM transitoire (style.visibility = ''). Quand
un clic sur une ligne de Répéteur déclenche EN PARALLÈLE "Ouvrir la ligne
cliquée" + "Rendre visible" + "Modifier une donnée", la branche
"Modifier une donnée" est asynchrone (aller-retour serveur) et termine
après les deux autres, synchrones. Son refreshRuntimeData() qui suit
regénère alors "mail content" depuis son état par défaut, écrasant le
"Rendre visible" qui venait tout juste d'être posé — le panneau redevient
invisible. Un second clic sur le MÊME mail "corrige" l'affichage car la
donnée est déjà à jour, donc la Condition ne redéclenche plus l'action de
modification, plus de refresh, plus d'écrasement ; mais ouvrir un AUTRE
mail reproduisait le même écrasement.
Fix : avant de remplacer wrapper.innerHTML, sauvegarder le style inline,
la classe et les data-* de chaque élément du sous-arbre, puis les
réappliquer juste après la regénération — la résolution des {{champ}}
reste correcte (c'est le but premier de la fonction) sans plus annuler
les changements posés par une action "Modifier un élément" au même clic.
Les trois actions du graphe (ouvrir la ligne, rendre visible, modifier la
donnée) restent connectées telles quelles, sans aucun retrait.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bug remonté : cliquer sur une ligne de Répéteur "semblait recharger la
page", et devenait impossible à recliquer une deuxième fois - alors que
le graphe de logique voulu (modifier la donnée + rendre visible un détail
+ "Ouvrir la ligne cliquée") reste sur le MÊME écran que le Répéteur.
Cause : showScreen() rejouait INCONDITIONNELLEMENT les déclencheurs "À
l'affichage de l'écran" et relançait la timeline d'animation depuis le
début à chaque appel - même quand l'écran cible est déjà celui affiché
(le cas normal pour "Ouvrir la ligne cliquée" combinée à une action
"Modifier un élément → Visibilité" sur le même clic, pensées pour
fonctionner ensemble SUR le même écran qu'un Répéteur). Ça rejouait donc
les animations d'entrée et pouvait faire repasser la visibilité à son
état initial via un déclencheur "affichage", entrant en conflit avec
l'action "Rendre visible" du même clic - d'où l'impression de
rechargement, et le blocage : reflow/re-rendu qui se disputent avec
l'état attendu.
Fix : showScreen() ne fait plus rien du tout si l'écran ciblé est déjà
celui affiché - aucun changement visuel à faire, donc aucune raison de
rejouer son "premier affichage". Changer vers un écran DIFFÉRENT continue
de tout rejouer normalement. Les trois actions du graphe restent
déclenchées à chaque clic, sans plus se marcher dessus.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Problème remonté : un déclencheur "Au clic" posé sur un Répéteur exécute
le MÊME graphe pour n'importe quelle ligne cliquée - or une Condition
("Si is_opened est égal à Non") ou une action "Modifier une donnée" ne
pouvaient viser qu'une ligne FIXE, choisie à la création du nœud dans
l'éditeur. Impossible donc de dire "modifie le champ DE LA LIGNE QUE JE
VIENS DE CLIQUER", puisque cette ligne n'est justement jamais connue à
l'avance.
Nouvelle valeur sentinelle CLICKED_ROW_ID (-1, screens/flow/constants.py,
ne collisionne jamais avec un vrai id de ligne) proposée en tête de TOUTE
liste déroulante "Ligne concernée" (clause principale et clauses
supplémentaires d'un nœud Condition, cible d'une action "Modifier une
donnée") : "🖱️ Ligne cliquée (Répéteur)".
Résolution au moment de l'exécution, pas à la création du nœud :
- Condition (évaluée côté client) : readFieldValue() (play.html) résout
-1 en window.lastClickedRowId, déjà capturé par bindClicks() au clic sur
une ligne de Répéteur (déjà utilisé par "Ouvrir la ligne cliquée").
- Action "Modifier une donnée" (exécutée côté serveur) : le client envoie
clicked_row_id dans le corps de la requête POST ; flow_node_run_data.py
ne s'en sert que si le nœud vise justement CLICKED_ROW_ID, sinon la
ligne fixe stockée sur le nœud reste utilisée normalement.
Ajoute tests/test_flow_clicked_row.py (ligne cliquée seule modifiée,
absence de clic = no-op plutôt que plantage, non-régression d'une cible
fixe, présence de l'option dans l'éditeur).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre
du Répéteur, Donnée liée, condition de visibilité) proposait déjà une
valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de
comparer à une variable globale (voir db/global_vars/), qui change elle
aussi en cours de partie mais n'est rattachée à aucun objet précis.
Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans
ambiguïté de "{{Objet.champ}}", qui attend toujours un point) :
_resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle
via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un
champ d'objet. Le panneau de propriétés gagne un troisième mode
"Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet",
avec la liste déroulante des variables existantes.
Corrige au passage un bug latent découvert en testant bout en bout : un
Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en
mode jouable avec TypeError - render_repeater.py substitue lui aussi
directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte
aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis
l'ajout de la condition de visibilité - déjà corrigé pour le chemin
générique (apply_ctx.py) mais pas pour ce chemin séparé.
Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans
le même message) existe déjà depuis un tour précédent (voir
insertFieldAtCursor()) - vérifié toujours fonctionnel.
Ajoute tests/test_filter_value_global_variable.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Depuis le dernier correctif, supprimer un élément supprime aussi en
silence tout nœud de la Logique de la scène qui le référence (déclencheur/
action, voir delete_element.py) - nécessaire pour éviter le plantage
"FOREIGN KEY constraint failed", mais l'utilisateur n'était jamais prévenu
qu'un bout de sa logique disparaissait en même temps.
Nouvelle route GET .../elements/<id>/delete-impact (element_delete_
impact.py) : calcule, sans rien supprimer, combien de nœuds de la Logique
de la scène référencent cet élément OU l'un de ses descendants (partage
element_descendant_ids.py avec delete_element.py, pour rester exactement
cohérent avec ce qui sera réellement supprimé).
Les deux boutons "supprimer" de screen_edit.html (élément sélectionné, et
onglet d'un widget Onglets) ouvrent maintenant une modale custom
(deleteConfirmModal, même famille que le sélecteur d'icônes) au lieu du
confirm() natif du navigateur : elle interroge cette route juste après
ouverture et affiche un avertissement dédié si le nombre remonté est non
nul, avant que l'utilisateur ne confirme quoi que ce soit - impossible à
faire avec confirm(), dont le texte est figé au moment du rendu de la
page. La confirmation soumet ensuite le formulaire normalement (via
requestSubmit(), intercepté par pjax.js comme n'importe quel autre
formulaire).
Ajoute deux tests pour la nouvelle route (impact nul, impact non nul sans
rien supprimer).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouveau groupe "Ombre" dans le panneau de propriétés, à côté de "Bordure"
(même universalité — voir UNIVERSAL_CONTROLS) : décalage X/Y, flou,
étendue, couleur et opacité, combinés en une seule valeur CSS box-shadow
(couleur+opacité fusionnées en rgba(), un <input type="color"> seul ne
portant pas de canal alpha). Décalages/flou/étendue tous à 0 = pas
d'ombre, même convention que border-width à 0 = pas de bordure.
Nouveau ctype "shadow" (c_shadow.py), suit exactement le même principe
que le ctype "size" déjà existant (size_override_controls.py) : plusieurs
entrées de formulaire pour un seul réglage, composées/décomposées dans
save_element_controls.py et control_value.py plutôt que passées par le
chemin générique clé->valeur.
Ajoute tests/test_shadow_controls.py (présence dans le panneau,
composition de la valeur CSS, aller-retour dans le formulaire, remise à
zéro qui retire l'ombre).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Une fois "Lier à un objet de données" réglé (widget Texte/Titre...),
propose maintenant les champs de CET objet en liste déroulante juste sous
le champ "Contenu", avec un bouton "+ Ajouter" qui insère "{{nom_du_champ}}"
à l'emplacement du curseur - plutôt que d'avoir à taper cette syntaxe à
la main sans savoir quels noms de champs existent réellement.
controls_with_values.py pose field_options sur le contrôle "content"
quand _data_definition_id est réglé (réutilise data_binding_options, déjà
calculé pour data_filtre_champ/data_filtre2_champ) ; insertFieldAtCursor()
(screen_edit.html) fait l'insertion via selectionStart/selectionEnd puis
déclenche un événement "input" pour que l'enregistrement automatique se
déclenche normalement, comme une saisie manuelle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bug remonté avec une "Mail card" (icône enveloppe fermée si is_opened
est à "Non", ouverte si "Oui") : les DEUX variantes s'affichaient (ou
aucune), selon la ligne.
Cause : _compare() (filter_repeater_rows.py, utilisé par la condition de
visibilité, le Répéteur et Donnée liée) ne reconnaissait "1"/"true"/"vrai"
comme valeur vraie pour un champ booléen — jamais "oui", pourtant le SEUL
vocabulaire que l'app affiche elle-même pour ce type de champ partout
ailleurs (voir data_list.html : "Oui" si vrai sinon "Non"). Une valeur de
comparaison fixe tapée "Oui" retombait donc silencieusement à "faux",
et comme l'opérateur et le champ étaient par ailleurs corrects, ça
donnait l'impression que la condition "ne voyait" rien : sur la ligne où
is_opened=faux, les DEUX cartes ("égal à Oui" et "égal à Non", toutes
deux évaluées comme "égal à faux") s'affichaient ensemble ; sur la ligne
où is_opened=vrai, aucune des deux.
Fix : "oui" ajouté à l'ensemble des valeurs reconnues comme vraies,
côté Python (_compare) ET côté JS (compareValues() dans play.html, qui
doit rester alignée — utilisée par les nœuds Condition de la Logique de
la scène), cette dernière au passage rendue insensible à la casse comme
son équivalent Python (elle ne l'était pas du tout).
Ajoute un test de régression dédié à ce cas précis.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le calcul JS "object-fit:contain" du tour précédent gardait l'aspect-ratio
(Portrait/Paysage/Carré) au prix de marges vides sur les côtés dès que la
fenêtre n'avait pas exactement ce ratio - "prendre toute la place
disponible" et "garder l'aspect-ratio" sont deux exigences contradictoires
dans ce cas, et c'est la première qui doit l'emporter dans l'éditeur.
Le canevas (#canvas) remplit donc maintenant .canvasFrame à 100% x 100%,
sans plus tenir compte de l'aspect-ratio choisi dans l'éditeur - ce
réglage continue de s'appliquer normalement à l'aperçu jouable ("Jouer",
voir screen_set_aspect.py/play.html), qui reste la référence pour le
rendu final. Padding de .canvasFrame réduit au minimum. fitCanvasToFrame()
et son calcul en pixels n'ont plus lieu d'être - retirés entièrement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le calcul purement CSS du tour précédent (aspect-ratio + height:100% +
max-width:100%, pour que le canevas garde ses proportions tout en tenant
dans la zone visible) laissait en pratique le canevas bien plus petit que
l'espace réellement disponible - le calcul de taille "auto" d'un élément
non remplacé dans ce contexte flex n'est pas fiable.
Remplacé par un calcul en JavaScript (fitCanvasToFrame()) : mesure la
taille réelle de .canvasFrame et calcule la plus grande taille en pixels
qui tient à la fois en largeur ET en hauteur pour l'aspect-ratio courant
(l'équivalent d'un "object-fit:contain"), posée directement en style
inline sur #canvas. Recalculé à l'ouverture de l'écran, au changement de
format (Portrait/Paysage/Carré), au redimensionnement de la fenêtre, et à
chaque rafraîchissement du panneau (#canvas étant recréé à chaque
sélection d'élément, sa taille calculée était perdue à chaque fois).
Sous 1300px (mise en page empilée), le style inline est explicitement
effacé pour laisser la règle CSS de repli (pleine largeur, page qui
défile normalement) reprendre la main.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le fil d'Ariane disparaît entièrement sur cette page (breadcrumb_wrap vide)
- le nom de l'écran est de toute façon déjà éditable juste au-dessus, dans
le panneau "Éléments" (voir le tour précédent) - et .content-builder perd
son padding par défaut hérité de .content, pour que le canevas commence le
plus haut possible.
Change aussi la façon dont le canevas est dimensionné : il était
jusqu'ici contraint par la LARGEUR (width:100%), ce qui pouvait le rendre
bien plus haut que la fenêtre pour un format Portrait - obligeant à
défiler .builderCanvasArea (toolbar/onglets compris) pour voir le bas de
l'écran. Il est maintenant contraint par la HAUTEUR disponible
(height:100%, la largeur se déduisant de l'aspect-ratio), avec
max-width:100% en secours si c'est la largeur qui manque en premier -
l'écran entier reste donc visible sans défiler. Le défilement, s'il reste
nécessaire (fenêtre très basse), se fait maintenant sur .canvasFrame
lui-même, jamais sur .builderCanvasArea (repassé à overflow:hidden) : la
barre d'onglets et la barre d'outils restent toujours fixes en haut.
Ajoute le pendant pour le repli en page empilée (< 1300px, une seule
colonne) : le "letterboxing" par hauteur suppose une chaîne de hauteurs
définies qui n'existe plus une fois empilé - revient alors à un
dimensionnement par largeur, cohérent avec une page qui défile normalement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remplace l'ancien panneau du bas rétractable/redimensionnable à la souris
(Logique de la scène, Timeline d'animation) par un système d'onglets au
centre de l'éditeur - Écran / Logique de la scène / Timeline d'animation
- un seul visible à la fois, occupant systématiquement tout l'espace
disponible (switchBuilderTab() dans screen_edit.html). Changer d'onglet
équivaut à "fermer" celui qu'on quitte ; plus besoin d'une poignée de
redimensionnement séparée puisque l'onglet actif prend déjà toute la
place. Supprime au passage tout l'ancien mécanisme (toggleFlowPanel/
toggleAnimPanel, poignées flowResizeHandle/animResizeHandle, classe CSS
.flowPanel) devenu inutile.
Déplace aussi le renommage de l'écran, le bouton "Jouer depuis le début"
et le choix du format d'aperçu (Portrait/Paysage/Carré) - jusqu'ici
au-dessus du canevas - dans le panneau flottant "Éléments" (celui qui
porte déjà ce nom, à gauche) : des réglages qu'on touche rarement une
fois l'écran en construction, qui n'ont plus besoin de rester en
permanence visibles au-dessus de la zone de travail.
Corrige au passage un bug découvert pendant ce tour : sur la page "Nouvel
objet" (2 colonnes), le bouton "+ Ajouter un champ" avait disparu -
placé APRÈS la zone de liste à défilement (flex-grow:1) dans la colonne
de droite, un flex-grow imprévisible dans ce contexte le poussait hors de
vue. Déplacé avant la liste (statique, toujours visible), pattern déjà
éprouvé ailleurs sur cette même page.
Deux tests mis à jour pour refléter intentionnellement la nouvelle
structure : la présence de "animTabPanel" (au lieu de l'ancien
"animPanel"), et un marqueur plus précis pour distinguer le bloc de
propriétés "Onglets" d'une simple occurrence du même texte dans une liste
déroulante de la Logique de la scène (qui apparaît désormais plus tôt
dans le document, cet éditeur de flow étant maintenant un onglet du
centre plutôt qu'un panneau tout en bas de page).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Généralise le principe déjà utilisé pour le tableau de bord et l'édition
d'objet à toutes les pages restantes : liste des jeux, écrans, éléments de
jeu, variables, nouvel objet, formulaire de données, tableau des données
d'un objet - colonne de gauche pour créer/agir, colonne de droite pour ce
qui existe déjà, chaque colonne défilant pour son propre compte.
Pour un formulaire qui doit rester UN SEUL <form> à cheval sur les deux
colonnes (nouvel objet : nom à gauche, champs à droite ; formulaire de
données : bouton Enregistrer à gauche, champs à droite), nouvelle classe
.formPassthrough ("display:contents") : le <form> ne devient pas lui-même
une boîte dans la mise en page flex, seul .twoCol à l'intérieur compte.
Supprime au passage .content-page/.formScroll/body.pageBody (le gabarit à
une colonne introduit au tour précédent, plus utilisé nulle part) et
.list/.listRow/.listRowFlex/.rowTitle/.rowSub/.rowActions/.addBtn (les
cartes à deux lignes remplacées par les tableaux denses et la nav
compacte) - du CSS mort plutôt que deux systèmes qui se chevauchent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Généralise le principe déjà en place pour l'édition d'objet (la page
n'occupe jamais plus que la hauteur de la fenêtre, seules ses zones
internes défilent) à toutes les pages restantes : liste des jeux, tableau
de bord d'un jeu, écrans, éléments de jeu, variables, nouvel objet,
formulaire de données, tableau des données d'un objet. Seuls l'éditeur
d'écran/d'élément et l'aperçu jouable restent en dehors (déjà exclus par
leur propre gabarit plein écran, ou pas concernés).
Nouveau gabarit générique à une colonne (body.pageBody + .content-page +
.scrollArea) sur le même principe que .content-objectEdit, plus une
variante .formScroll pour un formulaire long à défilement interne (liste
de champs d'un nouvel objet, formulaire de données) tout en gardant les
boutons d'action toujours visibles. Les listes/tableaux remplacés par le
format dense .fieldsTable (déjà utilisé pour le tableau de bord) pour
rester cohérent et afficher plus de lignes à l'écran.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Réutilise le système déjà en place pour l'édition d'objet (body.objectEditBody
+ .content-objectEdit) : la page occupe exactement la hauteur de la fenêtre,
seules les deux colonnes défilent chacune de leur côté si besoin - plus la
page elle-même, qui ne doit jamais défiler.
Colonne de gauche ("Créer") : nouvelle nav à une ligne (.navRow/.navList,
icône + libellé, sous-titre en info-bulle) plutôt que des cartes à deux
lignes - plus dense, plus sobre.
Colonne de droite ("Objets définis") : remplace les cartes par le tableau
compact à en-tête collant déjà utilisé pour les champs d'un objet
(.fieldsTable) - format nettement plus adapté à beaucoup de lignes.
Réduit aussi le padding par défaut de .listRow/.dangerZone (encore utilisés
par la page Variables), dans le même esprit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remplace le bouton en pointillés ("+ Définir un nouvel objet") par une
entrée de liste identique aux autres actions (Écrans, Variables, Jouer...)
— plus cohérent visuellement, comme demandé après revue du rendu réel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Retrait de la création rapide de variable globale depuis le sélecteur de
la Condition de visibilité (bouton "+ Créer") : une variable globale est
désormais gérée comme un objet "jeu" à part entière, avec une vraie page
CRUD ("Variables", nouvelle entrée du menu de gauche) - création, édition
du type/valeur, suppression. Le nom reste volontairement immuable après
création (c'est par ce nom qu'une condition de visibilité ou une action
"Modifier une variable" la référence - la renommer casserait ces réglages
en silence), d'où db.update_global_variable qui ne touche que type/valeur.
Redesign du tableau de bord du jeu (game_dashboard.html) en deux
colonnes : à gauche tout ce qu'on peut créer (écrans, éléments de jeu,
variables, jouer, nouvel objet) plus les paramètres du jeu (renommer/
supprimer) ; à droite ce qui a déjà été créé (objets définis). Remplace
les cartes Bulma par le système de mise en page compact déjà défini dans
style.css (.twoCol/.listRow/.dangerZone/.addBtn) mais jamais utilisé
jusqu'ici - plus dense et cohérent avec le reste de l'éditeur.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Nouveau panneau "Condition de visibilité" disponible dans les propriétés
de TOUT élément (widget) : permet de masquer un élément en mode jouable
selon deux moyens, au choix -
- une variable globale (nom + type + valeur, une seule par jeu, stockée
dans une nouvelle table _global_variables) ;
- le champ d'un objet de données existant (même convention "état de
partie" - une seule ligne - déjà utilisée par la Jauge).
Une variable ne servant à rien si elle ne peut jamais changer en cours de
partie, ajoute aussi une nouvelle action de flow "Modifier une variable
globale" (parallèle à "Modifier une donnée"), avec sa propre route
d'exécution serveur et son sous-formulaire dans l'éditeur de logique de
scène. Une variable peut aussi se créer à la volée depuis le sélecteur du
panneau de visibilité, sans quitter les propriétés de l'élément.
La condition n'est évaluée qu'en mode jouable (/game/<slug>/play), jamais
dans l'éditeur, pour que l'élément reste toujours sélectionnable. Un
élément masqué se réévalue en direct après toute action "Modifier une
donnée/variable", via le même mécanisme de rafraîchissement déjà utilisé
par la Jauge et le Répéteur.
Corrige au passage deux bugs découverts en testant bout en bout : (1)
apply_ctx plantait sur le nouveau marqueur interne _forge_play_mode (un
booléen parmi les {{champ}} à substituer, qui attend des chaînes) ; (2)
_compare traitait toute valeur booléenne stockée en chaîne ("0" inclus,
donc toujours vraie en Python) comme vraie - correct pour les champs
d'objet (entiers SQLite) mais faux pour les variables globales (toujours
stockées en texte).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Redundant since dragging an element onto a container row in the tree now does the same thing (see move_element_to_container.py). The element_add_child route itself stays (still used by tests and available as an API), only the UI entry point is gone. Removed the now-dead .containerContentGroup CSS along with it.