Compare commits

...
17 Commits
Author SHA1 Message Date
w-vandal 1020aaaf2f Merge pull request 'Dev' (#5) from dev into main
Build and deploy / deploy (push) Successful in 12s
Build and deploy / build-and-push (push) Successful in 1m2s
Reviewed-on: #5
2026-08-30 09:29:41 +00:00
williamandClaude Sonnet 5 a7a315cce7 Corrige un bug moteur : plusieurs déclencheurs "Au clic" (ou survol) sur le même élément n'exécutaient que le premier
Diagnostic effectué directement sur projects/test/game.db de
l'utilisateur (suite à son signalement "je ne parviens pas à mettre fin
à la surbrillance") : deux nœuds Déclencheur distincts ("Au clic",
id=4 et id=42) référençaient le même trigger_element_id=65. La fonction
findTriggerNode() utilisait Array.find(), qui ne retourne que la
PREMIÈRE correspondance — le second nœud (celui qui devait couper la
surbrillance) n'était donc jamais exécuté, silencieusement, quel que
soit le graphe construit dans l'éditeur.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 06:18:00 +02:00
96 changed files with 3199 additions and 199 deletions
+1
View File
@@ -9,6 +9,7 @@ __pycache__/
venv/
env/
Bug/
regles/
# Variables d'environnement / secrets locaux
.env
+8
View File
@@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge favicon">
<rect width="120" height="120" rx="24" fill="#161b25"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge (sur fond blanc)">
<rect x="1" y="1" width="118" height="118" rx="16" fill="#ffffff" stroke="#e2e5eb" stroke-width="1"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge">
<!-- Carte de fond — le mark n'est jamais utilisé sans sa carte -->
<rect width="120" height="120" rx="16" fill="#161b25"/>
<!-- Facette gauche — orange, accent 1 -->
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<!-- Facette droite — ambre, accent 2 -->
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<!-- Trois chevrons — entailles de la couleur de la carte -->
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

+2
View File
@@ -29,6 +29,7 @@ from .send_email import send_password_reset_email, EmailNotConfiguredError
from .update_profile import update_profile
from .count_admins import count_admins
from .delete_user import delete_user
from .update_email import update_email, EmailUpdateError
__all__ = [
"users_db_path", "ensure_users_schema", "is_first_user",
@@ -44,4 +45,5 @@ __all__ = [
"consume_password_reset_token", "TOKEN_TTL_MINUTES",
"send_password_reset_email", "EmailNotConfiguredError",
"update_profile", "count_admins", "delete_user",
"update_email", "EmailUpdateError",
]
+2 -5
View File
@@ -1,16 +1,13 @@
import re
import pyotp
from werkzeug.security import generate_password_hash
from .ensure_schema import ensure_users_schema
from .connection import connect
from .email_validation import is_valid_email
from .get_user_by_email import get_user_by_email
from .is_first_user import is_first_user
from .password_strength import password_strength
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class UserCreationError(Exception):
"""Erreur de validation lisible par un humain (affichée telle quelle
@@ -29,7 +26,7 @@ def create_user(email, password, nom, prenom):
email = (email or "").strip().lower()
nom = (nom or "").strip()
prenom = (prenom or "").strip()
if not _EMAIL_RE.match(email):
if not is_valid_email(email):
raise UserCreationError("Adresse email invalide.")
if not nom or not prenom:
raise UserCreationError("Nom et prénom sont requis.")
+7
View File
@@ -0,0 +1,7 @@
import re
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def is_valid_email(email):
return bool(EMAIL_RE.match((email or "").strip()))
+23
View File
@@ -0,0 +1,23 @@
from .connection import connect
from .email_validation import is_valid_email
from .get_user_by_email import get_user_by_email
class EmailUpdateError(Exception):
"""Erreur de validation lisible par un humain (affichée telle quelle
sur la page de profil) — mêmes règles qu'à l'inscription (format
valide, unicité), voir create_user.py."""
def update_email(user_id, new_email):
new_email = (new_email or "").strip().lower()
if not is_valid_email(new_email):
raise EmailUpdateError("Adresse email invalide.")
existing = get_user_by_email(new_email)
if existing and existing["id"] != user_id:
raise EmailUpdateError("Un compte existe déjà avec cette adresse email.")
conn = connect()
conn.execute("UPDATE _users SET email = ? WHERE id = ?", (new_email, user_id))
conn.commit()
conn.close()
return new_email
+42
View File
@@ -0,0 +1,42 @@
"""Assemble static/style.css à partir de styles/ — un simple concaténage,
PAS une compilation Sass (voir styles/bulma-override.css pour l'incident
qui a motivé ce choix : Bulma 1.x expose déjà tout son thème via des
variables CSS standard, aucun compilateur n'est nécessaire pour le
recolorer). Ordre important — chaque fichier ne fait que redéfinir des
variables/règles posées par le précédent :
1. static/vendor/bulma.min.css — Bulma 1.0.2 vendoré tel quel, non
modifié (voir son en-tête pour la provenance).
2. styles/bulma-override.css — variables --bulma-* de la charte Forge.
3. styles/forge-tokens.css — tokens --forge-*/--accent... de l'app.
4. styles/forge-custom.css — CSS custom de l'app (floatPanel, canvas,
éditeurs...), consomme les tokens ci-dessus.
Lancer `python build_css.py` après toute modification sous styles/ ou
static/vendor/bulma.min.css."""
import os
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_STYLES_DIR = os.path.join(_BASE_DIR, "styles")
_OUTPUT = os.path.join(_BASE_DIR, "static", "style.css")
_SOURCES = [
os.path.join(_BASE_DIR, "static", "vendor", "bulma.min.css"),
os.path.join(_STYLES_DIR, "bulma-override.css"),
os.path.join(_STYLES_DIR, "forge-tokens.css"),
os.path.join(_STYLES_DIR, "forge-custom.css"),
]
def build():
chunks = []
for path in _SOURCES:
with open(path, encoding="utf-8") as f:
chunks.append(f"/* ---- {os.path.relpath(path, _BASE_DIR)} ---- */\n" + f.read())
css = "\n\n".join(chunks)
with open(_OUTPUT, "w", encoding="utf-8") as f:
f.write(css)
print(f"Assemblé : {len(_SOURCES)} fichiers -> {_OUTPUT} ({len(css)} octets)")
if __name__ == "__main__":
build()
+8 -1
View File
@@ -32,6 +32,7 @@ from .games.game_meta import game_meta
from .games.create_game import create_game
from .games.update_game_name import update_game_name
from .games.delete_game import delete_game
from .games.move_game import move_game
from .definitions.list_definitions import list_definitions
from .definitions.get_definition import get_definition
@@ -60,10 +61,15 @@ from .global_vars.update_global_variable import update_global_variable
from .global_vars.delete_global_variable import delete_global_variable
from .global_vars.delete_global_variable_by_id import delete_global_variable_by_id
from .custom_events.list_custom_events import list_custom_events
from .custom_events.get_custom_event import get_custom_event
from .custom_events.create_custom_event import create_custom_event
from .custom_events.update_custom_event import update_custom_event
__all__ = [
"PROJECTS_DIR", "FIELD_TYPES", "GLOBAL_VARIABLE_TYPES",
"slugify", "table_name_for", "game_dir", "db_path", "connect",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game",
"list_games", "game_meta", "create_game", "update_game_name", "delete_game", "move_game",
"list_definitions", "get_definition", "create_definition", "rename_definition",
"definitions_referencing", "add_field_to_definition", "update_field", "delete_field",
"delete_definition",
@@ -72,4 +78,5 @@ __all__ = [
"list_global_variables", "get_global_variable", "create_global_variable",
"update_global_variable_value", "update_global_variable", "delete_global_variable",
"delete_global_variable_by_id",
"list_custom_events", "get_custom_event", "create_custom_event", "update_custom_event",
]
+11 -1
View File
@@ -2,7 +2,17 @@
import os
PROJECTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects")
# Surchargeable via FORGE_PROJECTS_DIR (même schéma que
# auth/connection.py::FORGE_USERS_DB_PATH) — utilisé par le serveur
# joueur autonome (publish/player_app_template.py), qui pose cette
# variable AVANT d'importer db/ pour pointer vers son propre dossier
# "projects/" embarqué plutôt que celui, réel, du poste de développement.
# Résolu ici, à l'IMPORT (pas par un appel de fonction) : la variable
# d'environnement doit donc déjà être posée avant le tout premier
# `import db`.
PROJECTS_DIR = os.environ.get("FORGE_PROJECTS_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "projects"
)
# Types de champ exposés dans l'interface -> type de colonne SQLite réel.
FIELD_TYPES = {
View File
+26
View File
@@ -0,0 +1,26 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def create_custom_event(slug, name, description=""):
"""Idempotent par nom (même convention que create_global_variable.py) :
si le nom existe déjà, ne touche à rien et renvoie simplement son id
existant plutôt que de lever une erreur — sans risque en cas de
double clic/soumission depuis l'onglet Événements (screen_edit.html)."""
name = (name or "").strip()
if not name:
return None
ensure_custom_events_schema(slug)
conn = connect(slug)
existing = conn.execute("SELECT id FROM _custom_events WHERE name = ?", (name,)).fetchone()
if existing:
conn.close()
return existing["id"]
conn.execute(
"INSERT INTO _custom_events (name, description) VALUES (?, ?)",
(name, (description or "").strip()),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
@@ -0,0 +1,27 @@
from ..connection import connect
def ensure_custom_events_schema(slug):
"""Migration légère (même principe que ensure_global_vars_schema.py) :
crée _custom_events si absente. Un événement personnalisé vit pour
TOUT le jeu (pas par écran, pas par modèle) : "name" est donc UNIQUE —
c'est par ce nom qu'on le désigne dans l'interface, même si les nœuds
du graphe le référencent par id technique (voir
screens/flow/ensure_flow_schema.py, trigger_custom_event_id/
target_custom_event_id). Un événement est une pure NOTIFICATION,
sans paramètre : "déclencher" ne fait que signaler, jamais choisir
un élément — c'est à l'ÉCOUTEUR (déclencheur → condition → action)
de décider quoi faire, avec ses propres réglages habituels."""
conn = connect(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _custom_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+12
View File
@@ -0,0 +1,12 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def get_custom_event(slug, event_id):
if not event_id:
return None
ensure_custom_events_schema(slug)
conn = connect(slug)
row = conn.execute("SELECT * FROM _custom_events WHERE id = ?", (event_id,)).fetchone()
conn.close()
return dict(row) if row else None
+10
View File
@@ -0,0 +1,10 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def list_custom_events(slug):
ensure_custom_events_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _custom_events ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
+16
View File
@@ -0,0 +1,16 @@
from ..connection import connect
from .ensure_custom_events_schema import ensure_custom_events_schema
def update_custom_event(slug, event_id, description=""):
"""Le NOM reste volontairement immuable après création — comme une
variable globale (voir update_global_variable.py) : c'est par ce nom
qu'on désigne l'événement dans l'interface, mais surtout par son ID
TECHNIQUE que les nœuds du graphe le référencent
(trigger_custom_event_id/target_custom_event_id) — le renommer ne
casserait donc rien niveau logique."""
ensure_custom_events_schema(slug)
conn = connect(slug)
conn.execute("UPDATE _custom_events SET description = ? WHERE id = ?", ((description or "").strip(), event_id))
conn.commit()
conn.close()
+23
View File
@@ -0,0 +1,23 @@
import os
from ..game_dir import game_dir
def move_game(old_slug, new_slug):
"""Renomme le dossier d'un jeu vers un nouveau slug — utilisé quand un
utilisateur change son adresse email (voir routes/auth/profile.py,
project_slug = slugify(email) pour un compte "user") : le dossier
physique du jeu doit suivre. Rend le nouveau slug unique de la même
façon que create_game() si, par un hasard extrême, il correspond déjà
à un dossier existant. Renvoie le slug FINAL (potentiellement suffixé),
à enregistrer comme nouveau project_slug."""
if old_slug == new_slug:
return old_slug
base = new_slug
n = 2
while os.path.isdir(game_dir(base)):
base = f"{new_slug}-{n}"
n += 1
new_slug = base
os.rename(game_dir(old_slug), game_dir(new_slug))
return new_slug
+3
View File
@@ -0,0 +1,3 @@
"""publish — empaquette un jeu en exécutable Windows autonome (zip avec
serveur interne, voir build_package.py) : le bouton "Publier" de la barre
de navigation d'un jeu (templates/base.html)."""
+113
View File
@@ -0,0 +1,113 @@
"""Assemble le zip exporté d'un jeu (bouton "Publier", voir
routes/publish/publish_game.py) : un dossier autonome contenant Python
portable + Flask (vendorés une fois, voir vendor_runtime.py), une copie
figée du jeu (screens/db/filters/core minimal + templates/play.html +
le sous-ensemble utile de static/), sa base SQLite et ses fichiers
envoyés (uploads/), et les scripts de lancement (player_app.py/run.bat).
Ne modifie JAMAIS les fichiers réels de l'app (tout se passe dans un
dossier de travail temporaire, supprimé après envoi de la réponse) ni la
vraie base de données du jeu (copiée, jamais déplacée)."""
import os
import shutil
import tempfile
import zipfile
import db
from . import vendor_runtime
_PUBLISH_DIR = os.path.dirname(os.path.abspath(__file__))
_BASE_DIR = os.path.dirname(_PUBLISH_DIR)
# Sous-ensemble de static/ réellement nécessaire au mode jouable (voir
# templates/play.html) — jamais les assets propres à l'éditeur seul
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
_STATIC_ITEMS = ["style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css"]
_SLUG_PLACEHOLDER = "_SLUG_PLACEHOLDER"
def _copy_engine_sources(staging_dir):
"""Copie la partie du moteur nécessaire au mode jouable SEUL (jamais
l'éditeur ni l'authentification — voir le constat d'exploration dans
le plan : screens/ et db/ sont déjà totalement découplés de auth//
routes/, core/flask_app.py et core/jinja_filters.py sont les 2 SEULS
fichiers de core/ dont ils dépendent réellement)."""
for pkg in ("screens", "db", "filters"):
shutil.copytree(os.path.join(_BASE_DIR, pkg), os.path.join(staging_dir, pkg),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
core_dir = os.path.join(staging_dir, "core")
os.makedirs(core_dir, exist_ok=True)
for fname in ("__init__.py", "flask_app.py", "jinja_filters.py"):
shutil.copy2(os.path.join(_BASE_DIR, "core", fname), os.path.join(core_dir, fname))
templates_dir = os.path.join(staging_dir, "templates")
os.makedirs(templates_dir, exist_ok=True)
shutil.copy2(os.path.join(_BASE_DIR, "templates", "play.html"), os.path.join(templates_dir, "play.html"))
static_dir = os.path.join(staging_dir, "static")
for item in _STATIC_ITEMS:
src = os.path.join(_BASE_DIR, "static", item)
dst = os.path.join(static_dir, item)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.isdir(src):
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
def _copy_game_data(staging_dir, slug):
"""Copie le dossier RÉEL du jeu (game.db + uploads/, voir
db.game_dir) tel quel — jamais déplacé, jamais modifié."""
src = db.game_dir(slug)
dst = os.path.join(staging_dir, "projects", slug)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("index.html", "index.css", "index.js"))
def _write_player_app(staging_dir, slug):
template_path = os.path.join(_PUBLISH_DIR, "player_app_template.py")
with open(template_path, encoding="utf-8") as f:
content = f.read()
content = content.replace(_SLUG_PLACEHOLDER, slug)
with open(os.path.join(staging_dir, "player_app.py"), "w", encoding="utf-8") as f:
f.write(content)
def _write_run_bat(staging_dir):
src = os.path.join(_PUBLISH_DIR, "run_bat_template.bat")
shutil.copy2(src, os.path.join(staging_dir, "run.bat"))
def _zip_dir(staging_dir, zip_path):
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
rel = os.path.relpath(full, staging_dir)
zf.write(full, rel)
def build_game_zip(slug):
"""Construit le zip et renvoie son chemin (dans un dossier temporaire
— à l'appelant de le supprimer une fois la réponse HTTP envoyée, voir
routes/publish/publish_game.py)."""
game = db.game_meta(slug)
python_dir, pylibs_dir = vendor_runtime.ensure_vendor_ready()
staging_dir = tempfile.mkdtemp(prefix="forge_publish_")
try:
shutil.copytree(python_dir, os.path.join(staging_dir, "python-embed"))
shutil.copytree(pylibs_dir, os.path.join(staging_dir, "python-embed", "pylibs"))
_copy_engine_sources(staging_dir)
_copy_game_data(staging_dir, slug)
_write_player_app(staging_dir, slug)
_write_run_bat(staging_dir)
zip_fd, zip_path = tempfile.mkstemp(prefix="forge_publish_", suffix=".zip")
os.close(zip_fd)
_zip_dir(staging_dir, zip_path)
finally:
shutil.rmtree(staging_dir, ignore_errors=True)
return zip_path, game["name"]
+107
View File
@@ -0,0 +1,107 @@
"""player_app.py — serveur autonome généré par Forge Engine pour UN SEUL
jeu (voir publish/build_package.py) : aucune installation externe requise,
lancé par double-clic sur run.bat (Python portable + Flask embarqués à
côté de ce fichier). Reprend telles quelles les routes utilisées par le
mode jouable (voir templates/play.html) — jamais l'éditeur ni
l'authentification, absents de cet export.
SLUG est remplacé littéralement par build_package.py au moment de la
publication (voir _SLUG_PLACEHOLDER) — ce fichier n'est jamais exécuté
tel quel depuis le dépôt source."""
import json
import os
import socket
import sys
import threading
import webbrowser
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Le python311._pth de Python embeddable (voir publish/vendor_runtime.py)
# ne met sur sys.path QUE le dossier de python.exe lui-même (python-embed/)
# et "pylibs" — jamais le dossier de CE script, qui vit un cran au-dessus
# (sibling de python-embed/). Sans cette ligne, "import core"/"db"/
# "screens" échoue avec ModuleNotFoundError bien qu'ils soient juste à
# côté de ce fichier.
sys.path.insert(0, _BASE_DIR)
# Doit être posé AVANT tout import de db/ : db/constants.py lit
# FORGE_PROJECTS_DIR à l'IMPORT, pas à l'appel — pointe ici vers le
# dossier "projects/" embarqué à côté de ce fichier, jamais un chemin du
# poste où le jeu a été publié.
os.environ["FORGE_PROJECTS_DIR"] = os.path.join(_BASE_DIR, "projects")
from flask import jsonify, redirect, render_template, request, send_from_directory, url_for # noqa: E402
from core.flask_app import app # noqa: E402
from core import jinja_filters # noqa: E402,F401 - enregistre les filtres Jinja (elstyle/eltransform/elabel/colname)
import db # noqa: E402
import screens # noqa: E402
# Pas de CSRF/auth dans cet export (un seul jeu, aucune connexion) —
# play.html appelle quand même csrf_token() en Jinja (voir base.html/
# play.html) : un no-op le laisse fonctionner tel quel, sans le forker.
app.jinja_env.globals["csrf_token"] = lambda: ""
SLUG = "_SLUG_PLACEHOLDER"
@app.route("/")
def index():
return redirect(url_for("game_play", slug=SLUG))
@app.route("/game/<slug>/play")
def game_play(slug):
game = db.game_meta(SLUG)
payload = screens.full_game_payload(SLUG)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
)
@app.route("/game/<slug>/runtime-payload")
def runtime_payload(slug):
return jsonify(screens.full_game_payload(SLUG))
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-data", methods=["POST"])
def flow_node_run_data(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee":
return jsonify({"ok": False}), 400
if node.get("target_row_id") == screens.CLICKED_ROW_ID:
body = request.get_json(silent=True) or {}
node = {**node, "target_row_id": body.get("clicked_row_id")}
ok = screens.apply_data_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/flow/nodes/<int:node_id>/run-variable", methods=["POST"])
def flow_node_run_variable(slug, node_id):
node = screens.get_flow_node(SLUG, node_id)
if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_variable":
return jsonify({"ok": False}), 400
ok = screens.apply_variable_action(SLUG, node)
return jsonify({"ok": ok})
@app.route("/game/<slug>/uploads/<path:filename>")
def uploaded_file(slug, filename):
return send_from_directory(os.path.join(db.game_dir(SLUG), "uploads"), filename)
def _open_browser(port):
webbrowser.open(f"http://127.0.0.1:{port}/")
if __name__ == "__main__":
# Port libre choisi au hasard par l'OS (jamais le même serveur à
# relancer sur un port déjà pris par une partie précédente encore
# ouverte) — le navigateur est ouvert automatiquement, l'utilisateur
# n'a jamais besoin de connaître ni taper le port.
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
threading.Timer(1.0, _open_browser, args=(port,)).start()
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python-embed\python.exe player_app.py
pause
+91
View File
@@ -0,0 +1,91 @@
"""Prépare, une seule fois par poste, tout ce qu'il faut pour empaqueter
un jeu en exécutable autonome : Python portable (embeddable, python.org)
et les paquets pip nécessaires (Flask + ses dépendances), mis en cache
localement sous data/publish_vendor/ (non commité, voir .gitignore —
même dossier que data/secret_key/data/users.db, déjà runtime-only).
La toute PREMIÈRE publication d'un jeu sur un poste donné télécharge ces
~15 Mo (connexion internet nécessaire, une seule fois) ; toutes les
publications suivantes réutilisent le cache, hors-ligne. Le JOUEUR qui
reçoit le zip final, lui, n'a jamais besoin d'internet — ce cache ne
concerne que la machine qui PUBLIE.
Fonctions séparées et mockables (download_python_embeddable/
vendor_flask) pour que les tests ne déclenchent jamais un vrai
téléchargement."""
import os
import subprocess
import sys
import urllib.request
import zipfile
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VENDOR_DIR = os.environ.get("FORGE_PUBLISH_VENDOR_DIR") or os.path.join(_BASE_DIR, "data", "publish_vendor")
PYTHON_VERSION = "3.11.9"
PYTHON_EMBED_URL = f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip"
PYTHON_EMBED_DIR = os.path.join(VENDOR_DIR, "python-embed-amd64")
PYLIBS_DIR = os.path.join(VENDOR_DIR, "pylibs")
# Mêmes versions que requirements.txt — seul Flask est listé explicitement,
# ses dépendances (Werkzeug/Jinja2/MarkupSafe/click/itsdangerous/blinker)
# suivent automatiquement via pip.
FLASK_SPEC = "Flask==3.0.3"
def download_python_embeddable():
"""Télécharge et extrait le ZIP Python embeddable officiel si absent
du cache — no-op si déjà présent (idempotent, sûr à rappeler à
chaque publication)."""
marker = os.path.join(PYTHON_EMBED_DIR, "python.exe")
if os.path.isfile(marker):
return PYTHON_EMBED_DIR
os.makedirs(PYTHON_EMBED_DIR, exist_ok=True)
zip_path = os.path.join(VENDOR_DIR, "python-embed-amd64.zip")
urllib.request.urlretrieve(PYTHON_EMBED_URL, zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(PYTHON_EMBED_DIR)
os.remove(zip_path)
_enable_pylibs_path(PYTHON_EMBED_DIR)
return PYTHON_EMBED_DIR
def _enable_pylibs_path(python_embed_dir):
"""Le ZIP embeddable désactive site-packages par défaut
(python311._pth ne contient que "python311.zip" et "."). On ajoute une
ligne "pylibs" (chemin relatif à python.exe) plutôt que de réactiver
tout le mécanisme "site" — les paquets vendorés (Flask...) sont de
simples dossiers purement Python, aucun besoin d'aller plus loin."""
pth_files = [f for f in os.listdir(python_embed_dir) if f.endswith("._pth")]
if not pth_files:
return
pth_path = os.path.join(python_embed_dir, pth_files[0])
with open(pth_path, encoding="utf-8") as f:
content = f.read()
if "pylibs" not in content:
with open(pth_path, "a", encoding="utf-8") as f:
f.write("\npylibs\n")
def vendor_flask():
"""pip install --target=... : installe Flask (paquets purs Python,
indépendants de la version exacte de l'interpréteur embeddable) dans
le cache, réutilisé par tous les exports suivants — no-op si déjà
fait."""
if os.path.isdir(PYLIBS_DIR) and os.listdir(PYLIBS_DIR):
return PYLIBS_DIR
os.makedirs(PYLIBS_DIR, exist_ok=True)
subprocess.run(
[sys.executable, "-m", "pip", "install", "--target", PYLIBS_DIR, "--no-compile", FLASK_SPEC],
check=True,
)
return PYLIBS_DIR
def ensure_vendor_ready():
"""Point d'entrée unique appelé par build_package.py — télécharge/
installe ce qui manque, puis renvoie (dossier_python_embed,
dossier_pylibs)."""
python_dir = download_python_embeddable()
pylibs_dir = vendor_flask()
return python_dir, pylibs_dir
+1 -1
View File
@@ -1 +1 @@
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars, publish, custom_events
+38
View File
@@ -32,6 +32,44 @@ def profile_update_name():
return _render(success="Informations mises à jour.")
@app.route("/profile/update-email", methods=["POST"])
def profile_update_email():
user = g.current_user
current_password = request.form.get("current_password", "")
new_email = request.form.get("email", "").strip().lower()
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe incorrect.")
try:
auth.update_email(user["id"], new_email)
except auth.EmailUpdateError as exc:
return _render(error=str(exc))
# Le dossier d'un compte "user" porte le nom de son adresse email (voir
# create_user.py/register_2fa.py, project_slug = slugify(email)) — il
# doit suivre le changement, sans quoi project_slug ne correspondrait
# plus à aucun dossier réel. Un "admin" n'a pas de project_slug dédié :
# rien à renommer pour ce rôle.
if user["role"] != "admin" and user.get("project_slug"):
new_slug = db.move_game(user["project_slug"], db.slugify(new_email))
auth.set_project_slug(user["id"], new_slug)
return _render(success="Adresse email mise à jour.")
@app.route("/profile/regenerate-recovery-codes", methods=["POST"])
def profile_regenerate_recovery_codes():
user = g.current_user
current_password = request.form.get("current_password", "")
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe incorrect.")
# Posés en session pour être affichés une seule fois par le modal déjà
# utilisé à l'inscription (voir core/recovery_codes_flash.py et
# templates/base.html) — les 10 anciens codes sont déjà invalidés à ce
# stade (auth.generate_recovery_codes supprime tout jeu précédent).
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user["id"])
return _render()
@app.route("/profile/change-password", methods=["POST"])
def profile_change_password():
user = g.current_user
+1
View File
@@ -0,0 +1 @@
from . import custom_event_new, custom_event_edit, custom_event_delete
@@ -0,0 +1,18 @@
from flask import request, redirect, url_for
import screens
from core.flask_app import app
@app.route("/game/<slug>/events/<int:event_id>/delete", methods=["POST"])
def custom_event_delete(slug, event_id):
"""Supprime aussi TOUS les nœuds Déclencheur/Action qui référencent cet
événement, sur toutes les scènes et tous les modèles (voir
screens/custom_events/delete_custom_event.py) — seule suppression
d'entité game-wide du moteur à vraiment cascader, sur demande
explicite (une variable globale ou une définition d'objet supprimée,
elles, laissent des références pendantes tolérées ailleurs)."""
screen_id = request.form.get("screen_id", type=int)
screens.delete_custom_event(slug, event_id)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+13
View File
@@ -0,0 +1,13 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/events/<int:event_id>/edit", methods=["POST"])
def custom_event_edit(slug, event_id):
description = request.form.get("description") or ""
screen_id = request.form.get("screen_id", type=int)
db.update_custom_event(slug, event_id, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
+20
View File
@@ -0,0 +1,20 @@
from flask import request, redirect, url_for
import db
from core.flask_app import app
@app.route("/game/<slug>/events/new", methods=["POST"])
def custom_event_new(slug):
"""Depuis l'onglet Événements de l'éditeur de scène/modèle
(screen_edit.html) — idempotent par nom (voir db.create_custom_event),
sans risque en cas de double soumission. screen_id (l'écran/modèle
depuis lequel on a créé l'événement) sert uniquement à revenir sur le
bon écran ensuite, jamais stocké sur l'événement lui-même (game-wide)."""
name = (request.form.get("name") or "").strip()
description = request.form.get("description") or ""
screen_id = request.form.get("screen_id", type=int)
if name:
db.create_custom_event(slug, name, description)
return redirect(url_for("screen_edit", slug=slug, screen_id=screen_id, tab="events"))
-1
View File
@@ -14,5 +14,4 @@ def game_play(slug):
payload = screens.full_game_payload(slug)
return render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
google_fonts_link=screens.GOOGLE_FONTS_LINK,
)
+1
View File
@@ -0,0 +1 @@
from . import publish_game
+28
View File
@@ -0,0 +1,28 @@
import os
from flask import after_this_request, send_file
import db
from publish.build_package import build_game_zip
from core.flask_app import app
@app.route("/game/<slug>/publish", methods=["POST"])
def publish_game(slug):
"""Construit et renvoie le zip exécutable autonome du jeu (voir
publish/build_package.py) — protégée par la même garde d'accès que
toutes les autres routes /game/<slug>/... (core/auth_guard.py),
aucune vérification supplémentaire nécessaire ici."""
zip_path, game_name = build_game_zip(slug)
@after_this_request
def _cleanup(response):
try:
os.remove(zip_path)
except OSError:
pass
return response
download_name = f"{db.slugify(game_name)}.zip"
return send_file(zip_path, as_attachment=True, download_name=download_name, mimetype="application/zip")
+5 -1
View File
@@ -44,6 +44,9 @@ def screen_edit(slug, screen_id):
screens_map = {s["id"]: s["name"] for s in all_screens}
animation_clips = screens.list_animation_clips(slug, screen_id)
global_variables = db.list_global_variables(slug)
custom_events = db.list_custom_events(slug)
custom_event_usages = screens.list_custom_event_usages(slug)
custom_events_map = {e["id"]: {"name": e["name"]} for e in custom_events}
return render_template(
"screen_edit.html",
@@ -63,7 +66,6 @@ def screen_edit(slug, screen_id):
data_operation_labels=screens.DATA_OPERATION_LABELS,
definitions=definitions, definitions_json=json.dumps(definitions_data),
selected_id=selected_id, selected_controls=selected_controls,
google_fonts_link=screens.GOOGLE_FONTS_LINK,
flow_nodes=flow_nodes, flow_edges=flow_edges,
flow_nodes_json=json.dumps(flow_nodes), flow_edges_json=json.dumps(flow_edges),
trigger_events=screens.TRIGGER_EVENTS, condition_operators=screens.CONDITION_OPERATORS,
@@ -81,4 +83,6 @@ def screen_edit(slug, screen_id):
animate_css_labels_json=json.dumps(screens.ANIMATE_CSS_LABELS),
fontawesome_icons=screens.FONTAWESOME_ICONS,
global_variables=global_variables,
custom_events=custom_events, custom_event_usages=custom_event_usages,
custom_events_map_json=json.dumps(custom_events_map),
)
+6 -2
View File
@@ -40,7 +40,6 @@ de fonctionner à l'identique.
from .widgets.registry import WIDGETS
from .widgets.container_widgets import CONTAINER_WIDGETS
from .widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from .widgets.font_options import GOOGLE_FONTS_LINK
from .widgets.fontawesome_icons import FONTAWESOME_ICONS
from .widgets.widget_meta import widget_meta
from .widgets.controls_with_values import controls_with_values
@@ -56,7 +55,9 @@ from .labels.element_action_properties import (
from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS
from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS
from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID
from .flow.constants import (
TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID,
)
from .rendering.render_element_html import render_element_html
from .rendering.trigger_for import _trigger_for
@@ -118,4 +119,7 @@ from .animations.update_animation_clip import update_animation_clip
from .animations.delete_animation_clip import delete_animation_clip
from .animations.animate_css_catalog import ANIMATE_CSS_CATALOG, ANIMATE_CSS_LABELS
from .custom_events.list_custom_event_usages import list_custom_event_usages
from .custom_events.delete_custom_event import delete_custom_event
from .payload.full_game_payload import full_game_payload
+9
View File
@@ -0,0 +1,9 @@
"""Tout ce qui touche à la fois _custom_events (db/custom_events/) ET
_flow_nodes/_flow_edges (screens/flow/) — jamais dans db/custom_events/,
pour la même raison que screens/elements/delete_element.py n'est pas
dans db/ : ce sont deux domaines différents (une entité game-wide, des
nœuds de graphe), le nettoyage croisé vit dans screens/, pas db/."""
from .list_custom_event_usages import list_custom_event_usages
from .delete_custom_event import delete_custom_event
__all__ = ["list_custom_event_usages", "delete_custom_event"]
@@ -0,0 +1,30 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def delete_custom_event(slug, event_id):
"""Seule suppression d'entité game-wide du moteur qui cascade
RÉELLEMENT (demande explicite) : une variable globale ou une
définition d'objet supprimée laisse des références pendantes
tolérées ailleurs (voir db/global_vars/, db/definitions/) — un
événement, lui, doit emporter avec lui TOUS ses écouteurs (nœuds
Déclencheur, trigger_custom_event_id) et déclencheurs (nœuds Action,
target_custom_event_id), sur TOUS les écrans ET modèles à la fois
(aucun filtre screen_id, comme screens/elements/delete_element.py —
un modèle est un écran caché, même table _flow_nodes)."""
ensure_flow_schema(slug)
conn = db.connect(slug)
conn.execute(
"""DELETE FROM _flow_edges WHERE from_node_id IN
(SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?)
OR to_node_id IN
(SELECT id FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?)""",
(event_id, event_id, event_id, event_id),
)
conn.execute(
"DELETE FROM _flow_nodes WHERE trigger_custom_event_id = ? OR target_custom_event_id = ?",
(event_id, event_id),
)
conn.execute("DELETE FROM _custom_events WHERE id = ?", (event_id,))
conn.commit()
conn.close()
@@ -0,0 +1,37 @@
import db
from ..flow.ensure_flow_schema import ensure_flow_schema
def list_custom_event_usages(slug):
"""Pour CHAQUE événement, où est-il écouté ("ecoute", nœud Déclencheur)
et déclenché ("declenche", nœud Action) — une seule requête plutôt
qu'une par événement, pour l'onglet Événements (screen_edit.html) qui
les affiche tous à la fois. Aucun filtre screen_id : couvre aussi bien
les écrans que les modèles (un modèle est un écran caché, voir
screens/element_types/create_element_type.py), exactement comme la
suppression en cascade (delete_custom_event.py).
Renvoie {event_id: [{"screen_id", "screen_name", "is_template", "role"}, ...]}."""
ensure_flow_schema(slug)
conn = db.connect(slug)
rows = conn.execute(
"""
SELECT DISTINCT n.id AS node_id, s.id AS screen_id, s.name AS screen_name, s.is_template AS is_template,
CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN n.trigger_custom_event_id
ELSE n.target_custom_event_id END AS event_id,
CASE WHEN n.trigger_custom_event_id IS NOT NULL THEN 'ecoute'
ELSE 'declenche' END AS role
FROM _flow_nodes n
JOIN _screens s ON s.id = n.screen_id
WHERE n.trigger_custom_event_id IS NOT NULL OR n.target_custom_event_id IS NOT NULL
ORDER BY s.name
"""
).fetchall()
conn.close()
usages = {}
for row in rows:
usages.setdefault(row["event_id"], []).append({
"screen_id": row["screen_id"], "screen_name": row["screen_name"],
"is_template": bool(row["is_template"]), "role": row["role"],
})
return usages
+17
View File
@@ -42,6 +42,14 @@ TRIGGER_EVENTS = [
# cacher/montrer un élément selon l'état de la partie SANS qu'un clic
# explicite soit nécessaire pour le réévaluer.
("affichage", "À l'affichage de l'écran"),
# Écoute un événement personnalisé (voir screens/custom_events/),
# déclenché depuis N'IMPORTE QUEL autre graphe (une autre scène, un
# autre modèle) via l'action "declencher_evenement" — ni élément ni
# écran précis : trigger_custom_event_id (quel événement) est le seul
# réglage propre à ce nœud, retrouvé par un scan global de
# gameData.flows côté client (voir dispatchGameEvent() dans
# templates/play.html), au même titre que findTriggerNode().
("evenement", "Sur un événement personnalisé"),
]
CONDITION_OPERATORS = [
@@ -69,4 +77,13 @@ FLOW_NODE_FIELDS = {
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable",
# Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement") ou
# un nœud Action déclenche (action_type="declencher_evenement") — un
# événement est une pure NOTIFICATION, sans paramètre : "déclencher"
# ne fait que signaler, jamais choisir un élément. C'est à l'ÉCOUTEUR
# (déclencheur → condition → action) de décider quoi faire ensuite,
# avec ses propres réglages habituels (target_element_id normal, pas
# de mécanisme dynamique dédié).
"trigger_custom_event_id", "target_custom_event_id",
}
+11
View File
@@ -72,5 +72,16 @@ def ensure_flow_schema(slug):
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT")
if "cond_variable_chemin" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT")
if "trigger_custom_event_id" not in cols:
# Événements personnalisés (voir screens/custom_events/) : quel
# événement un nœud Déclencheur écoute (trigger_event="evenement")
# ou un nœud Action déclenche (action_type="declencher_evenement").
# Pas de FK vers _custom_events : une suppression d'événement
# nettoie déjà ces colonnes elle-même (voir
# screens/custom_events/delete_custom_event.py), une contrainte
# ferait double emploi sans rien apporter de plus.
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN trigger_custom_event_id INTEGER")
if "target_custom_event_id" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_custom_event_id INTEGER")
conn.commit()
conn.close()
+1
View File
@@ -8,5 +8,6 @@ ACTION_TYPE_LABELS = {
"ouvrir_ligne": "Ouvrir la ligne de Répéteur cliquée",
"activer_onglet": "Afficher cet élément, masquer tous ses frères",
"attendre": "Attendre quelques secondes avant de continuer",
"declencher_evenement": "Déclencher un événement",
"rien": "Ne rien faire",
}
+1
View File
@@ -4,4 +4,5 @@ TRIGGER_LABELS = {
"affichage": "À l'affichage de l'écran",
"survol": "Au survol",
"fin_survol": "Fin du survol",
"evenement": "Sur un événement personnalisé",
}
-5
View File
@@ -8,8 +8,3 @@ FONT_OPTIONS = [
("'Montserrat', sans-serif", "Montserrat (Google Fonts, affirmée)"),
("'Playfair Display', serif", "Playfair Display (Google Fonts, élégante)"),
]
GOOGLE_FONTS_LINK = (
"https://fonts.googleapis.com/css2?"
"family=Poppins:wght@400;600;700&family=Roboto:wght@400;700&"
"family=Montserrat:wght@400;700&family=Playfair+Display:wght@400;700&display=swap"
)
+8
View File
@@ -0,0 +1,8 @@
<svg width="32" height="32" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge favicon">
<rect width="120" height="120" rx="24" fill="#161b25"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 703 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge (sur fond blanc)">
<rect x="1" y="1" width="118" height="118" rx="16" fill="#ffffff" stroke="#e2e5eb" stroke-width="1"/>
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<polyline points="46,43 60,35 74,43" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Forge">
<!-- Carte de fond — le mark n'est jamais utilisé sans sa carte -->
<rect width="120" height="120" rx="16" fill="#161b25"/>
<!-- Facette gauche — orange, accent 1 -->
<polygon points="60,15 30,75 60,105" fill="#ff5f2e"/>
<!-- Facette droite — ambre, accent 2 -->
<polygon points="60,15 90,75 60,105" fill="#ffb020"/>
<!-- Trois chevrons — entailles de la couleur de la carte -->
<polyline points="46,43 60,35 74,43" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="38,59 60,51 82,59" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="30,75 60,67 90,75" fill="none" stroke="#161b25" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 929 B

+242 -26
View File
File diff suppressed because one or more lines are too long
+7
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+423
View File
@@ -0,0 +1,423 @@
/* cyrillic-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WRhyzbi.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459W1hyzbi.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WZhyzbi.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wdhyzbi.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WRhyzbi.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459W1hyzbi.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459WZhyzbi.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wdhyzbi.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTjYgFE_.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTPYgFE_.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgFE_.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTjYgFE_.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTPYgFE_.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTLYgFE_.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/nuFiD-vYSZviVYUb_rj3ij__anPXDTzYgA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJbecmNE.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(files/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* devanagari */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839, U+A8E0-A8FF, U+11B00-11B09;
}
/* latin-ext */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(files/pxiByp8kv8JHgFVrLCz7Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* math */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
}
/* symbols */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
font-display: swap;
src: url(files/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+142
View File
@@ -0,0 +1,142 @@
/* bulma-override.css — palette Forge appliquée à Bulma 1.0.2 (vendoré tel
quel, non modifié, dans static/vendor/bulma.min.css) SANS recompilation
Sass : Bulma 1.x expose tout son thème via de vraies variables CSS
(--bulma-primary-h/-s/-l, --bulma-radius...), conçues justement pour être
surchargées après coup — voir regles/FORGE_ENGINE_TEMPLATE_BULMA.md, §6.
Chargé APRÈS bulma.min.css (voir build_css.py) : ce fichier ne fait que
redéfinir ses variables, jamais les siennes propres.
ATTENTION (leçon de la première version de ce chantier) : du code Python
de rendu du jeu (screens/rendering/render_jauge.py) fixe en ligne
--bulma-progress-value-background-color pour piloter le remplissage
d'une jauge — Bulma 1.x est donc une DÉPENDANCE FONCTIONNELLE réelle de
l'app, pas seulement un habillage visuel. Ne plus jamais changer de
version de Bulma sans vérifier `grep -rn "\-\-bulma-" screens/ templates/`
d'abord. */
:root {
--bulma-family-primary: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
--bulma-radius: 10px;
--bulma-radius-small: 8px;
--bulma-radius-medium: 12px;
--bulma-radius-large: 16px;
--bulma-radius-rounded: 999px;
--bulma-primary-h: 14deg;
--bulma-primary-s: 100%;
--bulma-primary-l: 59%;
--bulma-link-h: 14deg;
--bulma-link-s: 100%;
--bulma-link-l: 59%;
--bulma-info-h: 217deg;
--bulma-info-s: 91%;
--bulma-info-l: 60%;
--bulma-success-h: 160deg;
--bulma-success-s: 84%;
--bulma-success-l: 39%;
--bulma-warning-h: 39deg;
--bulma-warning-s: 100%;
--bulma-warning-l: 56%;
--bulma-danger-h: 4deg;
--bulma-danger-s: 86%;
--bulma-danger-l: 58%;
/* Un seul teinte partagée par tous les fonds (main/bis/ter/bordures),
seule la LUMINOSITÉ varie — c'est le modèle natif de Bulma 1.x, calé
ici sur les valeurs de la charte Forge (--forge-bg/-bg-secondary/
-card/-border, voir bulma-tokens.css). */
--bulma-scheme-h: 220deg;
--bulma-scheme-s: 26%;
--bulma-scheme-main-l: 6%;
--bulma-scheme-main-bis-l: 9%;
--bulma-scheme-main-ter-l: 12%;
--bulma-background-l: 12%;
--bulma-border-weak-l: 15%;
--bulma-border-l: 18%;
--bulma-text-weak-l: 60%;
--bulma-text-l: 93%;
--bulma-text-strong-l: 97%;
--bulma-text-title-l: 100%;
--bulma-box-radius: var(--bulma-radius-large);
--bulma-card-radius: var(--bulma-radius-large);
--bulma-navbar-height: 3.5rem;
}
/* Composants dont Bulma ne prévoit pas de variable dédiée (mise en page
propre au navbar/tableaux/onglets de l'app, badges maison) — voir §6 du
document de règles. */
.navbar {
background: var(--forge-bg-secondary);
border-bottom: 1px solid var(--forge-border);
}
.navbar-item.brand { gap: 8px; font-size: 1rem; }
.navbar-item, .navbar-link { color: var(--forge-text); }
.navbar-item.is-active, .navbar-item:hover, .navbar-link:hover {
color: var(--accent);
background: var(--forge-card);
}
.table thead th {
position: sticky;
top: 0;
z-index: 2;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.tabs.is-toggle li.is-active a { color: #14100c; }
.button.is-primary, button.primary {
background: var(--gradient);
border: none;
color: #14100c;
font-weight: 700;
}
.button.is-primary:hover, button.primary:hover { box-shadow: 0 6px 16px -6px var(--glow); }
.box, .card {
background: linear-gradient(180deg, var(--forge-card), var(--forge-bg-secondary));
box-shadow: none;
transition: box-shadow .15s ease;
}
.box:hover, .card:hover { box-shadow: 0 10px 25px -8px var(--glow); }
.forge-badge {
display: inline-block;
padding: 0.4rem 1rem;
border-radius: 999px;
background: rgba(255, 95, 46, 0.1);
border: 1px solid rgba(255, 95, 46, 0.35);
color: var(--accent);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.forge-badge.is-success { background: rgba(16, 185, 129, 0.12); border-color: rgba(16, 185, 129, 0.35); color: var(--status-success); }
.forge-badge.is-danger { background: rgba(240, 68, 56, 0.12); border-color: rgba(240, 68, 56, 0.35); color: var(--status-danger); }
/* Titre H1 en dégradé signature — réservé aux pages d'accueil/marketing et
à l'écran de connexion (§3/§5). */
.forge-gradient-title {
background: var(--gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Pages d'authentification (login/register/2FA/mot de passe oublié) :
seul endroit du site, avec l'accueil marketing, où le H1 en dégradé et
la grille de fond fine sont autorisés — absents de tous les écrans de
travail (tableau de bord, éditeur...). */
body.authBody {
background:
radial-gradient(circle at 1px 1px, rgba(232, 236, 244, 0.06) 1px, transparent 0) 0 0/26px 26px,
var(--forge-bg);
}
.authScreen { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 3.5rem); padding: 2rem 1rem; }
.authCard { width: 100%; max-width: 440px; }
.authCard.authCard-wide { max-width: 500px; }
.authTitle { font-size: clamp(1.75rem, 3.5vw, 2.5rem); margin-bottom: 0.5rem; }
+617
View File
@@ -0,0 +1,617 @@
/* Tokens (--bg, --panel, --accent...) définis une seule fois dans
forge-theme.scss, qui @importe ce fichier APRÈS Bulma — ce fichier
continue de référencer les mêmes noms de variable qu'avant la charte
Forge (voir les alias --bg/--panel/--panel2/--border/--text/--text-dim/
--danger dans forge-theme.scss), pour n'avoir à toucher qu'aux VALEURS
des tokens, jamais aux ~600 lignes de règles ci-dessous qui les
consomment déjà. */
*{ box-sizing:border-box; }
body{ margin:0; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; background:var(--bg); color:var(--text); }
h1{ font-size:20px; margin:0 0 6px; }
h2{ font-size:13px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-dim); margin:24px 0 8px; }
h3{ font-size:13px; color:var(--accent); margin:14px 0 6px; }
label{ display:block; font-size:12px; color:var(--text-dim); margin:8px 0 4px; }
input[type=text], input[type=number], input[type=date], textarea, select{
width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:6px;
color:var(--text); padding:8px 10px; font-size:13px; font-family:inherit;
}
input[type=checkbox]{ width:auto; margin-top:6px; }
/* .topbar : Bulma fournit déjà la mise en page (navbar) — cette classe ne
sert plus qu'à la repérer dans les layouts plein-écran
(body.builderBody/objectEditBody, voir plus bas) et à la rattacher au
thème sombre. */
.topbar{ border-bottom:1px solid var(--border); }
/* Barre de navigation d'UN JEU (voir base.html) — toujours visible entre
les différents éditeurs d'un même jeu, en ligne (contrairement à
.navList/.navRow ci-dessous, empilés verticalement dans le panneau
"Créer" du tableau de bord). */
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
.gameNavLink{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.gameNavLink:hover{ background:var(--panel2); border-color:var(--accent); }
.gameNavLink.active{ background:var(--panel2); border-color:var(--accent); color:var(--accent); }
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
.inline-form .fieldRow{ display:flex; gap:8px; align-items:flex-end; }
.inline-form .fieldRow input{ flex:1; }
.fieldRow{ display:flex; gap:10px; }
.fieldRow > div{ flex:1; }
.formField{ margin-bottom:6px; }
button{ background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:10px; padding:9px 14px; cursor:pointer; font-size:13px; }
/* Un <button> qui doit se comporter comme un <a> visuellement (ex. ouvrir
un panneau plutôt que suivre un href, mais garder l'apparence d'un lien
dans un tableau) — annule le style bouton par défaut ci-dessus. */
.linkLikeButton{ background:none; border:none; padding:0; color:var(--accent); text-decoration:none; font-size:inherit; }
.linkLikeButton:hover{ text-decoration:underline; }
/* button.primary : couleur/dégradé posés une seule fois, centralement,
dans forge-theme.scss (".button.is-primary, button.primary") — pas ici,
pour ne pas entrer en conflit de cascade avec cette règle centrale. */
button.smallBtn{ padding:5px 10px; font-size:11.5px; }
button.danger{ color:var(--danger); border-color:rgba(240,68,56,.4); }
.actionsRow{ margin-top:16px; }
/* Nav compacte à une seule ligne (icône + libellé) — pour une liste
d'actions fixe (ex. colonne "Créer" du tableau de bord d'un jeu) : sobre
et dense plutôt qu'une carte à deux lignes. */
.navList{ display:flex; flex-direction:column; gap:3px; }
.navRow{ display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.navRow:hover{ background:var(--panel2); border-color:var(--accent); }
.hint{ font-size:11.5px; color:var(--text-dim); line-height:1.5; }
.autosaveStatus{ display:inline-flex; align-items:center; gap:6px; transition:color .2s ease; }
.autosaveStatus.saving{ color:var(--text-dim); }
.autosaveStatus.saved{ color:var(--status-success); }
.warningBanner{ border:1px solid var(--danger); background:rgba(240,68,56,.1); color:var(--text);
border-radius:8px; padding:10px 14px; font-size:12.5px; margin:12px 0; }
.dangerZone{ border:1px solid rgba(240,68,56,.4); border-radius:8px; padding:10px 12px; margin-top:14px; }
.dangerZone h3{ color:var(--danger); margin-top:0; }
.fieldsExistingRow{ display:flex; align-items:center; justify-content:space-between;
padding:8px 10px; border:1px solid var(--border); border-radius:6px; margin-bottom:6px; font-size:12.5px; }
.fieldsExistingRow form{ margin:0; }
.fieldRowCard{ margin-bottom:10px; }
.relationTarget{ margin-top:6px; }
/* Liste de conditions de "Donnée liée" (voir c_clause_list.py) : un nombre
ILLIMITÉ de lignes peut s'y accumuler — sans limite de hauteur, le
panneau de propriétés entier deviendrait interminable à faire défiler
dès qu'on en pose plus de 3-4. max-height + overflow-y borne cette
LISTE elle-même à une hauteur confortable, le reste du panneau (et le
bouton "+ Ajouter une condition" juste en dessous) reste toujours à
portée sans avoir à chercher le bas de la liste. */
.clauseListWrap{ max-height:260px; overflow-y:auto; padding-right:2px; }
.clauseRow{ margin-bottom:8px; }
.clauseRow:last-child{ margin-bottom:0; }
/* .dataTable : Bulma fournit déjà la mise en page du tableau (classes
"table is-fullwidth is-hoverable", voir data_list.html) — ceci ne fait
plus que tronquer les cellules trop longues avec "...", pour garder une
vue d'ensemble compacte (le détail complet s'ouvre dans la modale Bulma
ci-dessous via le bouton 👁️). */
.dataTable td{ max-width:220px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ---------- Détail d'une entrée (bouton 👁️) ---------- */
/* Contenu du panneau de détail (voir openRowDetailPanel() dans
game_dashboard.html) — affiche chaque champ en entier, sans troncature,
contrairement au tableau dense qui les tronque. */
.rowDetailField{ padding:10px 0; border-bottom:1px solid var(--border); }
.rowDetailField:last-child{ border-bottom:none; }
.rowDetailLabel{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; margin-bottom:4px; }
.rowDetailValue{ font-size:13.5px; white-space:pre-wrap; word-break:break-word; }
.content-wide{ max-width:1600px; }
.twoCol{ display:flex; gap:24px; align-items:flex-start; margin-top:10px; }
.twoCol .colLeft{ flex:0 0 340px; }
.twoCol .colRight{ flex:1; min-width:0; }
@media (max-width: 820px){
.twoCol{ flex-direction:column; }
.twoCol .colLeft{ flex:none; width:100%; }
/* En dessous de 820px, les deux colonnes s'empilent : le défilement
interne séparé par colonne n'a plus de sens (il n'y a plus qu'une
colonne) — on revient à une page classique qui défile normalement,
comme pour l'éditeur d'écran ci-dessous. */
body.objectEditBody{ height:auto; overflow:visible; }
body.objectEditBody > main.content{ overflow:visible; }
.content-objectEdit .colLeft, .content-objectEdit .colRight{ height:auto; overflow:visible; }
.fieldsTableWrap{ max-height:60vh; }
}
/* ---------- Modifier un objet : tableau des champs existants ---------- */
/* Comme l'éditeur d'écran (body.builderBody, plus bas) : la page ne défile
jamais elle-même, seules les zones internes (colonne de gauche, tableau
des champs) défilent chacune de leur côté — indispensable dès qu'un objet
a beaucoup de champs, pour garder une vue d'ensemble sans perdre le
formulaire "Ajouter un champ" hors de l'écran. */
body.objectEditBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.objectEditBody > #pageChrome{ flex:0 0 auto; }
body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-objectEdit{ width:100%; display:flex; flex-direction:column; min-height:0; }
.content-objectEdit > h1, .content-objectEdit > .dashPanelHeader, .content-objectEdit > .hint, .content-objectEdit > form, .content-objectEdit > .warningBanner{ flex:0 0 auto; }
/* Un <form> qui doit envelopper les DEUX colonnes à la fois (un seul
formulaire soumis ensemble, ex. object_form.html : nom de l'objet à
gauche, liste de champs à droite) ne doit pas devenir lui-même une boîte
dans la mise en page flex — sinon .twoCol à l'intérieur perd la hauteur
disponible qu'il doit occuper. "display:contents" le rend transparent :
.twoCol se comporte comme s'il était directement enfant de
.content-objectEdit. */
.formPassthrough{ display:contents; }
.content-objectEdit .twoCol{ flex:1 1 auto; min-height:0; align-items:stretch; }
.content-objectEdit .colLeft{ overflow-y:auto; height:100%; }
.content-objectEdit .colRight{ display:flex; flex-direction:column; min-height:0; height:100%; }
.content-objectEdit .colRight h2{ flex:0 0 auto; }
.content-objectEdit .colRight > .hint, .content-objectEdit .colRight > button{ flex:0 0 auto; }
.fieldsTableWrap{ flex:1 1 auto; min-height:0; overflow:auto; border:1px solid var(--border); border-radius:8px; }
.fieldsTable{ width:100%; border-collapse:collapse; font-size:12.5px; }
.fieldsTable th, .fieldsTable td{ border-bottom:1px solid var(--border); padding:8px 10px; text-align:left; white-space:nowrap; }
.fieldsTable th{ position:sticky; top:0; background:var(--panel2); color:var(--text-dim); z-index:1; }
.fieldsTable tbody tr:hover{ background:rgba(255,255,255,.02); }
.fieldsTable tbody tr:last-child td{ border-bottom:none; }
.fieldsTable td input, .fieldsTable td select{ min-width:120px; width:auto; }
.fieldsTableActions{ display:flex; gap:6px; }
/* ---------- Éditeur d'écrans (façon Elementor/Bricks) ---------- */
/* La page de l'éditeur d'écran ne doit jamais défiler elle-même : elle
occupe exactement la hauteur de la fenêtre (topbar + zone de travail), et
ce sont les zones internes (panneaux, écran, panneau de logique) qui
défilent chacune de leur côté si besoin. */
body.builderBody{ display:flex; flex-direction:column; height:100vh; margin:0; overflow:hidden; }
body.builderBody > #pageChrome{ flex:0 0 auto; }
body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.content-builder{
/* .content (ci-dessus) pose "margin:0 auto" pour centrer une page normale
— mais sur cette page, le body est un flex-container en colonne
(body.builderBody), et une marge horizontale "auto" y désactive le
stretch d'un flex-item : sa largeur est alors recalculée en
"shrink-to-fit" à partir de son contenu, un calcul qui traverse toute
la chaîne flex/%/aspect-ratio jusqu'au canvas et redonne un résultat
légèrement différent à chaque changement de contenu du panneau (élément
sélectionné, grille affichée...) — d'où le canvas qui grandit/rétrécit
sans raison apparente à chaque interaction. "width:100%" donne une
taille définie à la place : la marge "auto" ne fait alors que centrer
l'espace restant une fois "max-width" atteint, comme voulu à l'origine. */
width:100%;
max-width:1600px;
/* Sans fil d'Ariane sur cette page (voir breadcrumb_wrap dans
screen_edit.html), .content garde quand même son padding par défaut
(28px en haut) — réduit ici pour que le canevas commence le plus haut
possible sur la page. */
padding:10px 20px 8px;
}
/* 3 colonnes : ajout d'éléments (gauche) / écran en grand (centre) / propriétés (droite) —
toutes les trois occupent exactement la même hauteur (celle qui reste
disponible), avec leur propre défilement interne si leur contenu dépasse. */
.builder3{ display:flex; gap:20px; align-items:stretch; margin-top:8px; flex:1 1 auto; min-height:0; }
/* overflow:hidden (pas auto) : le défilement doit se faire DANS le
canevas lui-même (voir .canvasFrame plus bas) ou dans l'onglet actif
(.flowCanvasWrap/.animTimelineWrap ont déjà leur propre défilement),
jamais sur cette zone entière — sans quoi la barre d'onglets et la barre
d'outils défileraient avec le contenu au lieu de rester fixes en haut. */
.builderCanvasArea{ flex:1 1 auto; min-width:0; height:100%; overflow:hidden; display:flex; flex-direction:column; }
/* ---------- Panneaux gauche/droite flottants ---------- */
/* Les deux panneaux (éléments de l'écran / propriétés) sont sortis du flux
flex de .builder3 (position:fixed) : .builderCanvasArea (flex:1 1 auto)
récupère automatiquement tout l'espace libéré, sans réglage
supplémentaire — les fermer donne donc une prévisualisation aussi large
que possible, aussi fidèle que possible à ce que "Jouer" affiche
réellement. Position et état ouvert/fermé mémorisés par écran (voir
floatPanel*() dans screen_edit.html) pour survivre à un changement de
sélection (qui remplace tout #builder3) ou un rechargement. */
.floatPanel{
position:fixed; top:90px; max-height:calc(100vh - 120px);
min-width:0; display:flex; flex-direction:column; z-index:60;
background:var(--panel); border:1px solid var(--border); border-radius:10px;
box-shadow:0 12px 40px rgba(0,0,0,.55);
}
.floatPanel.hidden{ display:none; }
/* Coin de redimensionnement (voir wireFloatPanelResize() dans
game_dashboard.html) — un panneau devient redimensionnable simplement en
lui ajoutant ce coin, JS mis à part rien d'autre à faire côté CSS. */
.floatPanelResizeHandle{
position:absolute; right:0; bottom:0; width:16px; height:16px; cursor:nwse-resize;
background:linear-gradient(135deg, transparent 50%, var(--border) 50%, var(--border) 60%, transparent 60%, transparent 70%, var(--border) 70%, var(--border) 80%, transparent 80%);
border-radius:0 0 10px 0;
}
.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%); }
/* Plein écran (voir makeFloatPanelFullscreenable() dans game_dashboard.html)
— juste un repère visuel (bordure accent) sur le panneau agrandi, tout le
positionnement réel est posé directement en style inline par le JS (pour
pouvoir revenir exactement à la position/taille d'avant en sortant). */
.floatPanel--fullscreen{ border-color:var(--accent); }
.floatPanelHeader{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:8px 10px; border-bottom:1px solid var(--border); background:var(--panel2);
border-radius:10px 10px 0 0; cursor:move; flex:0 0 auto; user-select:none;
}
.floatPanelHeader .floatPanelTitle{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; }
.floatPanelBody{ padding:12px; overflow-y:auto; min-height:0; flex:1 1 auto; }
.floatPanelReopenBtn{ position:fixed; top:90px; z-index:60; display:none; }
.floatPanelReopenBtn.visible{ display:inline-flex; }
#panelLeftReopenBtn{ left:24px; }
#propsPanelReopenBtn{ right:24px; }
.widgetGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
/* Icônes (widget Icône + galerie du panneau de propriétés) — SVG posé en
silhouette via "mask-image" plutôt qu'une police d'icônes (@font-face) :
se colore avec "color"/"background-color" comme du texte, MAIS ne dépend
d'aucun fichier de police externe — certains navigateurs/extensions
bloquent le chargement de polices web tierces (protection anti-
fingerprinting), ce qui rendait toutes les icônes Font Awesome invisibles
quelle que soit leur source (CDN ou même hébergées ici). Un simple SVG
affiché en masque n'est jamais concerné par ce blocage. Voir
render_icone.py (widget) et fontawesome_icons.py (liste + chemin des
fichiers, statiques dans static/icons/). */
.icon-svg{
display:inline-block; width:1em; height:1em; flex:0 0 auto;
background-color:currentColor;
-webkit-mask-image:var(--icon-url); mask-image:var(--icon-url);
-webkit-mask-size:contain; mask-size:contain;
-webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
-webkit-mask-position:center; mask-position:center;
}
/* Galerie d'icônes (panneau de propriétés) — voir
fontawesome_icons.py et addIconElement()/filterIconGallery() plus bas. */
.iconGallery{ display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; max-height:220px; overflow-y:auto; margin-top:8px; }
.iconGalleryTile{
aspect-ratio:1; display:flex; align-items:center; justify-content:center;
background:var(--panel2); border:1px solid var(--border); border-radius:6px;
color:var(--text); cursor:grab; padding:0;
}
.iconGalleryTile .icon-svg{ width:16px; height:16px; }
.iconGalleryTile:hover{ border-color:var(--accent); color:var(--accent); }
.iconGalleryTile.dragging{ opacity:0.4; }
.iconGalleryTile.iconHidden{ display:none; }
.widgetBtnForm{ margin:0; }
.widgetBtn{
width:100%; display:flex; flex-direction:column; align-items:center; gap:4px;
padding:12px 6px; border:1px solid var(--border); border-radius:10px; background:var(--panel);
color:var(--text); cursor:pointer;
}
.widgetBtn:hover{ border-color:var(--accent); background:var(--panel2); }
.widgetIcon{ font-size:22px; }
.widgetLabel{ font-size:11px; text-align:center; line-height:1.2; color:var(--text-dim); }
.canvasToolbar{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
.aspectBtn.active{ background:var(--accent); border-color:var(--accent); color:#fff; }
.canvasFrame{
/* flex:1 1 auto + min-height:0 : occupe tout l'espace restant sous la
barre d'outils dans .builderTabPanel (voir screen_edit.html). Le
canevas (voir .canvas ci-dessous) est réglé pour remplir EXACTEMENT
ce cadre (100% x 100%, sans respecter l'aspect-ratio Portrait/Paysage/
Carré dans l'éditeur — ce réglage ne sert qu'à l'aperçu jouable/"Jouer",
voir screen_set_aspect.py) : "prendre toute la place disponible" et
"garder l'aspect-ratio" sont deux exigences contradictoires dès que la
fenêtre n'a pas exactement ce ratio, la première l'emporte ici. C'est
LUI qui défile (overflow:auto) si jamais le contenu déborde malgré
tout — jamais .builderCanvasArea (voir plus haut), pour garder la
barre d'onglets/d'outils toujours visible. */
flex:1 1 auto; min-height:0; width:100%; padding:4px; border:1px solid var(--border); border-radius:16px; background:var(--panel);
display:flex; overflow:auto;
}
.canvas{
position:relative; width:100%; height:100%; flex:1 1 auto; margin:0; background:#0b0d12; border-radius:8px;
overflow:hidden; border:1px solid var(--border);
}
.canvasElement{
/* Pas d'overflow:hidden ici : une échelle (transform:scale) ou une
animation posée sur cet élément (voir la Timeline d'animation) vit sur
CE cadre (voir filters/element_style_filter.py) — le rogner clippait
l'effet à la taille d'origine de la boîte au lieu de le laisser
grossir/bouger avec elle. La zone de l'écran elle-même (.canvas,
ci-dessus) reste rognée, donc rien ne peut déborder de l'écran final. */
box-sizing:border-box; cursor:move; user-select:none;
border:1px dashed transparent;
}
.canvasElement:hover{ border-color:rgba(91,140,255,.5); }
.canvasElement.selected{ border:2px solid var(--accent); }
.canvasElementInner{ width:100%; height:100%; pointer-events:none; }
/* Surbrillance de l'élément sélectionné dans l'arborescence, posée en JS sur
sa propre balise (voir applySelectionHighlight) — seul moyen de la voir
sur le canevas quand cet élément est imbriqué dans un conteneur/répéteur/
groupe de champs, qui n'a pas de cadre ".canvasElement" séparé comme un
élément posé directement sur l'écran. Un "outline" qui dépasse la boîte
(offset positif) se fait couper par le premier ancêtre en overflow:hidden
(le canevas lui-même, ou un conteneur aux coins arrondis) dès que
l'élément touche un de ses bords — très fréquent pour une rangée de
jauges collée en haut d'un conteneur. Un box-shadow EN CREUX (inset)
reste toujours à l'intérieur de la boîte, jamais rogné, et épouse
naturellement les coins arrondis de l'élément lui-même. */
.elementHighlighted{
box-shadow: inset 0 0 0 2px var(--accent), inset 0 0 0 6px rgba(91,140,255,.2);
}
.resizeHandle{
position:absolute; right:0; bottom:0; width:14px; height:14px; cursor:nwse-resize;
background:var(--accent); border-radius:3px 0 6px 0; opacity:.85;
}
.listRow.selected{ border-color:var(--accent); background:var(--panel2); }
.childList{ display:flex; flex-direction:column; gap:6px; margin:2px 0 10px 18px; padding-left:10px; border-left:2px dashed var(--border); }
.childRow{ padding:7px 10px; font-size:12px; }
.childRow .rowTitle{ font-size:12px; }
/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
ligne par élément, indentation par imbrication réelle (profondeur
illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
pour rester lisible même avec beaucoup d'éléments. */
.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
.treeNode{ display:flex; flex-direction:column; }
.treeRow{ display:flex; align-items:center; gap:2px; }
.treeToggle{
flex:0 0 auto; width:16px; height:16px; padding:0; border:none; background:none; cursor:pointer;
color:var(--text-dim); font-size:10px; display:flex; align-items:center; justify-content:center;
transition:transform .12s ease;
}
.treeToggle:hover{ color:var(--text); }
.treeToggleSpacer{ flex:0 0 auto; width:16px; height:16px; }
.treeNode.collapsed > .treeRow > .treeToggle{ transform:rotate(-90deg); }
.treeNode.collapsed > .treeChildren{ display:none; }
.treeRowLink{
flex:1; display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
white-space:nowrap; overflow:hidden; min-width:0;
}
.treeRowLink:hover{ background:var(--panel2); }
.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
.treeIcon{ flex:0 0 auto; font-size:12px; }
.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
.treeRow.dragging{ opacity:0.4; }
.treeRow.dropBefore{ box-shadow: inset 0 2px 0 0 var(--accent); }
.treeRow.dropAfter{ box-shadow: inset 0 -2px 0 0 var(--accent); }
.treeRow.dropInside{ background:rgba(91,140,255,.25); box-shadow: inset 0 0 0 1px var(--accent); border-radius:4px; }
.contextMenu{
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
}
.contextMenu button{
display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
}
.contextMenu button:hover{ background:var(--panel2); }
/* Élément à onglets (voir render_onglets.py) : la barre d'onglets elle-même
est un vrai composant Bulma ("tabs" > "ul" > "li", voir render_onglets.py)
— il ne reste ici que les panneaux, que Bulma n'a pas de composant pour. */
.forgeTabPanels{ min-width:0; }
.forgeTabPanel{ padding-top:10px; }
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
font-weight:600; user-select:none; display:flex; align-items:center; justify-content:space-between;
}
.propGroup > summary::-webkit-details-marker{ display:none; }
.propGroup > summary::after{ content:"▸"; color:var(--text-dim); font-size:11px; transition:transform .15s; }
.propGroup[open] > summary::after{ transform:rotate(90deg); }
.propGroup > summary:hover{ background:var(--panel2); }
.propGroupBody{ padding:2px 12px 12px; }
.propGroupBody h3{ margin-top:0; }
/* .posGrid/.controlRow/.sizeField/.sliderRow/.alignGroup : Bulma fournit
déjà la mise en page (columns/field/control/buttons has-addons, voir
screen_edit.html) — ce qui reste ici n'est que ce que Bulma ne fournit
pas nativement : l'espacement propre à un curseur (slider) et son
pastille de valeur affichée à côté. */
.posGrid input:disabled{ opacity:0.4; cursor:not-allowed; }
.sliderRow{ display:flex; align-items:center; gap:10px; }
.sliderRow input[type=range]{ flex:1; }
.sliderValue{ min-width:44px; text-align:right; }
input[type=color]{ width:52px; height:34px; padding:2px; }
.propsPanel .sectionDivider, .floatPanel .sectionDivider{ border:none; border-top:1px solid var(--border); margin:14px 0; }
.actionAddForm{ margin-top:10px; }
.uploadRow{ margin-top:6px; }
.uploadInput{ font-size:11px; color:var(--text-dim); }
.uploadStatus{ display:block; margin-top:4px; }
code{ background:var(--panel2); border:1px solid var(--border); border-radius:4px; padding:1px 6px; font-size:11.5px; }
@media (max-width: 1300px){
/* Sous cette largeur, les 3 colonnes s'empilent : le principe "tout tient
dans la fenêtre, chaque zone défile pour elle-même" n'a plus de sens
(il n'y a plus qu'une seule colonne) — on revient à une page classique
qui défile normalement. */
body.builderBody{ height:auto; overflow:visible; }
body.builderBody > main.content{ overflow:visible; }
.builder3{ flex-direction:column; flex:none; }
.builderCanvasArea{ flex:none; width:100%; height:auto; max-height:none; overflow:visible; }
/* Le "letterboxing" par hauteur (voir .canvas plus haut) suppose une
chaîne de hauteurs définies jusqu'ici — qui n'existe plus une fois
.builderCanvasArea repassée en "height:auto" ci-dessus. Revient donc à
un dimensionnement par LARGEUR, comme une page qui défile normalement. */
.canvasFrame{ flex:none; overflow:visible; }
.canvas{ height:auto; width:100%; }
/* Trop peu de largeur pour qu'un panneau flottant ait un sens (il
couvrirait tout l'écran) : les deux rejoignent la pile normale et
restent toujours ouverts/visibles, quel que soit l'état mémorisé —
.hidden est ignoré grâce au "!important" sur "display" ci-dessous. */
.floatPanel{ position:static; flex:none; width:100%; height:auto; max-height:none; box-shadow:none; top:auto; left:auto; right:auto; display:flex !important; }
.floatPanelReopenBtn{ display:none !important; }
}
/* ---------- Onglets du centre : Écran / Logique de la scène / Timeline
d'animation ---------- */
/* Un seul onglet visible à la fois (voir switchBuilderTab() dans
screen_edit.html), chacun occupant tout l'espace disponible dans
.builderCanvasArea — remplace l'ancien panneau du bas rétractable/
redimensionnable à la souris : un onglet "prend toute la place" par
nature, pas besoin d'une poignée de redimensionnement séparée, et en
changer d'onglet équivaut à "fermer" celui qu'on quitte. */
.builderTabs{ display:flex; gap:2px; flex:0 0 auto; border-bottom:1px solid var(--border); margin-bottom:10px; }
.builderTabBtn{
background:transparent; border:none; border-bottom:2px solid transparent; border-radius:0;
padding:8px 12px; color:var(--text-dim); font-size:12.5px; cursor:pointer;
}
.builderTabBtn:hover{ color:var(--text); }
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
.builderTabPanel.active{ display:flex; }
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
objet" (voir game_dashboard.html) — classes distinctes de .builderTabs/
.builderTabBtn/.builderTabPanel (utilisées par le style, réutilisé tel
quel) pour que switchDashTab() (onglets du tableau de bord lui-même,
ciblage global) n'aille jamais les toucher par erreur. */
.objectPanelTabs{ margin:14px 0 10px; }
.objectPanelTabPanel{ display:none; flex-direction:column; }
.objectPanelTabPanel.active{ display:flex; }
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
.nodeForm label{ margin-top:8px; }
.flowCanvasWrap{ position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto; border:1px dashed var(--border); border-radius:8px; background:var(--bg); }
.flowCanvas{ position:relative; width:2200px; height:1400px; }
.flowEdgesSvg{ position:absolute; top:0; left:0; width:2200px; height:1400px; pointer-events:none; z-index:0; }
.flowEdgeLine{ stroke:var(--accent); stroke-width:2; pointer-events:stroke; cursor:pointer; }
.flowEdgeLine.flowEdgeTrue{ stroke:var(--status-success); }
.flowEdgeLine.flowEdgeFalse{ stroke:var(--danger); }
.flowNode{
position:absolute; width:220px; min-height:52px; border-radius:8px; border:1px solid var(--border);
background:var(--panel2); padding:10px 12px; font-size:12.5px; cursor:grab; z-index:1; user-select:none;
}
.flowNode-trigger{ border-color:var(--status-info); }
.flowNode-condition{ border-color:var(--status-warning); }
.flowNode-action{ border-color:var(--status-success); }
.flowNodeBody{ padding-right:16px; line-height:1.4; word-break:break-word; }
.flowNodeDelete{
position:absolute; top:4px; right:4px; width:18px; height:18px; padding:0; line-height:1;
font-size:11px; border-radius:4px; background:transparent; border:none; color:var(--text-dim);
}
.flowNodeDelete:hover{ color:var(--danger); }
.flowPort{
position:absolute; width:14px; height:14px; border-radius:50%; background:var(--panel);
border:2px solid var(--accent); top:50%; transform:translateY(-50%); cursor:crosshair; z-index:2;
}
.flowPortIn{ left:-8px; }
.flowPortOut{ right:-8px; }
.flowPortTrue{ right:-8px; top:35%; border-color:var(--status-success); }
.flowPortFalse{ right:-8px; top:65%; border-color:var(--danger); }
/* ---------- Timeline d'animation : panneau sous "Logique de la scène" ---------- */
.animTimelineWrap{
position:relative; width:100%; flex:1 1 auto; min-height:120px; overflow:auto;
border:1px dashed var(--border); border-radius:8px; background:var(--bg);
}
.animRuler{ display:flex; height:22px; border-bottom:1px solid var(--border); position:sticky; top:0; background:var(--bg); z-index:3; }
.animRulerSpacer{ position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2; border-right:1px solid var(--border); }
.animRulerTicksWrap{ position:relative; flex:0 0 auto; }
.animRulerTick{
position:absolute; top:0; bottom:0; font-size:10px; color:var(--text-dim);
border-left:1px solid var(--border); padding-left:3px;
}
.animTracks{ position:relative; }
.animTrack{ position:relative; height:38px; border-bottom:1px solid var(--border); display:flex; align-items:stretch; }
.animTrackLabel{
position:sticky; left:0; width:140px; flex:0 0 140px; background:var(--panel); z-index:2;
padding:0 8px; font-size:12px; display:flex; align-items:center; border-right:1px solid var(--border);
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.animTrackLane{ position:relative; flex:0 0 auto; }
.animClip{
position:absolute; top:5px; height:28px; border-radius:6px; background:var(--accent); color:#fff;
font-size:11px; padding:5px 8px; cursor:grab; overflow:hidden; white-space:nowrap; user-select:none;
box-sizing:border-box;
}
.animClip.selected{ outline:2px solid #fff; }
.animClipResizeHandle{ position:absolute; right:0; top:0; width:8px; height:100%; cursor:ew-resize; }
.animKeyframeRow{ align-items:flex-start; }
.animKeyframeRow textarea{ font-family:monospace; font-size:11px; }
/* ---------- Tableau de bord d'un jeu (game_dashboard.html) ---------- */
/* 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; }
/* ---------- Filtre de colonnes (onglet Données, game_dashboard.html) ---------- */
/* Menu déroulant compact sous le bouton "🔧 Colonnes" — un objet avec
beaucoup de champs rend le tableau illisible, ce menu permet d'en
masquer certains (voir toggleDataColumn() : agit directement sur
[data-col] plutôt que de reconstruire le tableau). */
.columnFilterWrap{ position:relative; display:inline-block; }
.columnFilterMenu{
position:absolute; top:100%; left:0; margin-top:4px; z-index:70;
background:var(--panel); border:1px solid var(--border); border-radius:8px;
padding:8px; box-shadow:0 12px 40px rgba(0,0,0,.55);
display:flex; flex-direction:column; gap:2px; min-width:180px; max-height:280px; overflow-y:auto;
}
.columnFilterMenu.hidden{ display:none; }
.columnFilterOption{ display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:4px; font-size:12.5px; white-space:nowrap; }
.columnFilterOption:hover{ background:var(--panel2); }
/* ---------- Panneau "Configurer la valeur" (variable Objet/Tableau) ---------- */
/* La colonne "Clé" n'a de sens que pour un Objet (paires clé/valeur) — un
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
game_dashboard.html. */
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
/* ---------- Inscription : schéma visuel du mot de passe ---------- */
/* Jauge + liste de critères mise à jour en direct (voir register.html) —
les mêmes 5 critères que auth/password_strength.py côté serveur. */
.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--status-danger); transition:width .15s ease, background-color .15s ease; }
.passwordStrengthFill.weak{ background:var(--status-danger); }
.passwordStrengthFill.medium{ background:var(--status-warning); }
.passwordStrengthFill.good{ background:var(--accent-2); }
.passwordStrengthFill.strong{ background:var(--status-success); }
.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
.passwordChecklist li.ok{ color:var(--status-success); }
.passwordChecklist li.ok::before{ content:"✓"; }
.passwordChecklist-compact{ flex-direction:row; flex-wrap:wrap; column-gap:10px; row-gap:2px; }
.passwordChecklist-compact li{ font-size:11px; }
/* ---------- Page de profil : compacte, sur 2 colonnes ---------- */
.profileSection{ padding-top:1.5rem; padding-bottom:1.5rem; }
.profileContainer{ max-width:980px; }
.profileNotif{ padding:0.6rem 0.9rem; margin-bottom:0.75rem; }
.profileGrid{ display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.profileCol{ display:flex; flex-direction:column; gap:14px; min-width:0; }
.profileBox{ padding:0.85rem 1rem; }
.profileBox-danger{ border:1px solid var(--status-danger); }
.profileForm .field{ margin-bottom:8px; }
.profileForm .field:last-child{ margin-bottom:0; }
.profileForm-row{ display:flex; flex-wrap:wrap; gap:8px; }
.profileForm-row .field{ flex:1 1 120px; margin-bottom:0; }
.profileForm-row .field.profileForm-full{ flex-basis:100%; }
@media (max-width:720px){
.profileGrid{ grid-template-columns:1fr; }
}
/* ---------- Inscription : QR code de la double authentification ---------- */
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
.totpQrWrap svg{ display:block; width:100%; height:auto; }
.recoveryCodesGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.recoveryCode{ display:block; text-align:center; padding:8px; background:var(--panel2); border-radius:10px; letter-spacing:1px; }
+38
View File
@@ -0,0 +1,38 @@
/* forge-tokens.css — tokens de la charte Forge (voir
regles/FORGE_ENGINE_TEMPLATE_BULMA.md, §1), exposés en :root pour rester
inspectables/utilisables tels quels par du JS ou du style inline.
Chargé APRÈS bulma-override.css (voir build_css.py) : indépendant de
Bulma, consommé par tout le CSS custom de l'app (forge-custom.css). */
:root {
--forge-bg: #0b0e14;
--forge-bg-secondary: #11151d;
--forge-card: #161b25;
--forge-border: #232a38;
--forge-text: #e8ecf4;
--forge-text-muted: #8b95a7;
--accent: #ff5f2e;
--accent-2: #ffb020;
--gradient: linear-gradient(120deg, var(--accent), var(--accent-2));
--glow: rgba(255, 95, 46, 0.25);
--status-success: #10b981;
--status-warning: #ffb020;
--status-danger: #f04438;
--status-info: #3b82f6;
/* Alias historiques : noms de variable déjà utilisés par tout le CSS
custom de l'app avant la charte Forge (styles/forge-custom.css) — pas
besoin de renommer les ~600 lignes de règles déjà écrites, seules les
VALEURS changent. */
--bg: var(--forge-bg);
--panel: var(--forge-card);
--panel2: var(--forge-bg-secondary);
--border: var(--forge-border);
--text: var(--forge-text);
--text-dim: var(--forge-text-muted);
--danger: var(--status-danger);
}
html, body {
background: var(--forge-bg);
color: var(--forge-text);
}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Mot de passe oublié — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Mot de passe oublié</h1>
<section class="section authScreen">
<div class="container authCard">
<h1 class="title authTitle forge-gradient-title">Mot de passe oublié</h1>
{% if sent %}
<div class="notification is-success">Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.</div>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Connexion — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Connexion</h1>
<section class="section authScreen">
<div class="container authCard">
<h1 class="title authTitle forge-gradient-title">Connexion</h1>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Code de vérification — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Code de vérification</h1>
<section class="section authScreen">
<div class="container authCard">
<h1 class="title authTitle forge-gradient-title">Code de vérification</h1>
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.</p>
{% if error %}
+68 -69
View File
@@ -1,88 +1,87 @@
{% extends "base.html" %}
{% block title %}Mon profil — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:560px;">
<h1 class="title">Mon profil</h1>
<section class="section profileSection">
<div class="container profileContainer">
<h1 class="title is-4 mb-3">Mon profil</h1>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
<div class="notification is-danger profileNotif">{{ error }}</div>
{% endif %}
{% if success %}
<div class="notification is-success">{{ success }}</div>
<div class="notification is-success profileNotif">{{ success }}</div>
{% endif %}
<div class="box">
<h2 class="title is-5">Informations</h2>
<p class="hint mb-4">Adresse email : <strong>{{ user.email }}</strong> ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})</p>
<form method="post" action="{{ url_for('profile_update_name') }}">
<div class="field">
<label class="label">Prénom</label>
<div class="control"><input class="input" type="text" name="prenom" value="{{ user.prenom }}" required></div>
<div class="profileGrid">
<div class="profileCol">
<div class="box profileBox">
<h2 class="title is-6 mb-2">Adresse email</h2>
<p class="hint mb-2">
{{ 'Compte administrateur' if user.role == 'admin' else 'Compte utilisateur' }}{% if user.role != 'admin' %} — le dossier de ton jeu ({{ user.project_slug }}) suit automatiquement{% endif %}.
</p>
<form method="post" action="{{ url_for('profile_update_email') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="email" name="email" value="{{ user.email }}" placeholder="Adresse email" required></div>
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><button type="submit" class="button primary is-small is-fullwidth">Changer d'adresse email</button></div>
</form>
</div>
<div class="field">
<label class="label">Nom</label>
<div class="control"><input class="input" type="text" name="nom" value="{{ user.nom }}" required></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Enregistrer</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">Changer de mot de passe</h2>
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off">
<div class="field">
<label class="label">Mot de passe actuel</label>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
<div class="box profileBox">
<h2 class="title is-6 mb-2">Informations</h2>
<form method="post" action="{{ url_for('profile_update_name') }}" class="profileForm profileForm-row">
<div class="field"><input class="input is-small" type="text" name="prenom" value="{{ user.prenom }}" placeholder="Prénom" required></div>
<div class="field"><input class="input is-small" type="text" name="nom" value="{{ user.nom }}" placeholder="Nom" required></div>
<div class="field profileForm-full"><button type="submit" class="button primary is-small is-fullwidth">Enregistrer</button></div>
</form>
</div>
<div class="field">
<label class="label">Nouveau mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="new_password" required autocomplete="new-password"></div>
</div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le nouveau mot de passe</label>
<div class="control"><input class="input" type="password" name="new_password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Changer le mot de passe</button>
</div>
</form>
</div>
<div class="box" style="border:1px solid #b33;">
<h2 class="title is-5">Supprimer mon compte</h2>
<p class="hint mb-4">
{% if user.role != 'admin' %}
Action définitive et irréversible : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés, sans possibilité de récupération.
{% else %}
Action définitive et irréversible : ton compte sera supprimé (tes jeux existants ne sont pas touchés).
{% endif %}
</p>
<form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off">
<div class="field">
<label class="label">Mot de passe actuel</label>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
<div class="box profileBox profileBox-danger">
<h2 class="title is-6 mb-2">Supprimer mon compte</h2>
<p class="hint mb-2">
{% if user.role != 'admin' %}
Action définitive : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés.
{% else %}
Action définitive : ton compte sera supprimé (tes jeux existants ne sont pas touchés).
{% endif %}
</p>
<form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><input class="input is-small" type="text" name="confirm_text" placeholder='Tape "SUPPRIMER" pour confirmer' required></div>
<div class="field"><button type="submit" class="button is-danger is-small is-fullwidth">Supprimer définitivement mon compte</button></div>
</form>
</div>
<div class="field">
<label class="label">Tape "SUPPRIMER" pour confirmer</label>
<div class="control"><input class="input" type="text" name="confirm_text" required></div>
</div>
<div class="profileCol">
<div class="box profileBox">
<h2 class="title is-6 mb-2">Changer de mot de passe</h2>
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><input class="input is-small" id="password" type="password" name="new_password" placeholder="Nouveau mot de passe" required autocomplete="new-password"></div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist passwordChecklist-compact" id="pwChecklist">
<li data-check="longueur">8+ car.</li>
<li data-check="majuscule">Majuscule</li>
<li data-check="minuscule">Minuscule</li>
<li data-check="chiffre">Chiffre</li>
<li data-check="special">Spécial</li>
</ul>
</div>
<div class="field"><input class="input is-small" type="password" name="new_password_confirm" placeholder="Confirmer le nouveau mot de passe" required autocomplete="new-password"></div>
<div class="field"><button type="submit" class="button primary is-small is-fullwidth">Changer le mot de passe</button></div>
</form>
</div>
<div class="field mt-4">
<button type="submit" class="button is-danger">Supprimer définitivement mon compte</button>
<div class="box profileBox">
<h2 class="title is-6 mb-2">Codes de récupération 2FA</h2>
<p class="hint mb-2">Remplace immédiatement les 10 codes précédents. Les nouveaux ne s'affichent qu'une fois, juste après.</p>
<form method="post" action="{{ url_for('profile_regenerate_recovery_codes') }}" autocomplete="off" class="profileForm">
<div class="field"><input class="input is-small" type="password" name="current_password" placeholder="Mot de passe actuel" required autocomplete="current-password"></div>
<div class="field"><button type="submit" class="button primary is-small is-fullwidth">Régénérer mes 10 codes</button></div>
</form>
</div>
</form>
</div>
</div>
</div>
</section>
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Inscription — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Créer un compte</h1>
<section class="section authScreen">
<div class="container authCard authCard-wide">
<h1 class="title authTitle forge-gradient-title">Créer un compte</h1>
<p class="hint mb-4">Un compte = un seul projet de jeu (illimité pour un compte administrateur). La double authentification (2FA) est obligatoire — l'étape suivante te fera scanner un QR code avec une application comme Google Authenticator.</p>
{% if error %}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Activer la double authentification — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Active la double authentification</h1>
<section class="section authScreen">
<div class="container authCard authCard-wide">
<h1 class="title authTitle forge-gradient-title">Active la double authentification</h1>
<p class="hint mb-4">Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.</p>
{% if error %}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}Nouveau mot de passe — Forge Engine{% endblock %}
{% block body_class %}authBody{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Choisir un nouveau mot de passe</h1>
<section class="section authScreen">
<div class="container authCard authCard-wide">
<h1 class="title authTitle forge-gradient-title">Choisir un nouveau mot de passe</h1>
{% if invalid %}
<div class="notification is-danger">Ce lien de réinitialisation est invalide ou a expiré.</div>
+119 -20
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Forge Engine{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
@@ -22,28 +22,13 @@
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. -->
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<div class="modal is-active" id="recoveryCodesModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Tes codes de récupération</h2>
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
<div class="recoveryCodesGrid">
{% for code in recovery_codes %}
<code class="recoveryCode">{{ code }}</code>
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
</div>
</div>
</div>
{% endif %}
<div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
<a href="{{ url_for('index') }}" class="navbar-item brand">
<img src="{{ url_for('static', filename='branding/forge-logo.svg') }}" alt="" width="32" height="32" style="border-radius:8px;">
<strong class="forge-gradient-title">Forge Engine</strong>
</a>
</div>
{% if g.get('current_user') %}
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
@@ -65,10 +50,124 @@
<nav class="gameNavBar" aria-label="Navigation du jeu">
<a href="{{ url_for('game_dashboard', slug=game.slug) }}" class="gameNavLink {{ 'active' if request.path == '/game/' ~ game.slug else '' }}" title="Écrants, objets, templates, variables">📊 Tableau de bord</a>
<a href="{{ url_for('game_play', slug=game.slug) }}" target="_blank" class="gameNavLink" title="Aperçu jouable du jeu">▶️ Jouer</a>
<button type="button" class="gameNavLink" onclick="window.forgeOpenPublishModal && window.forgeOpenPublishModal()" title="Exporte le jeu en exécutable Windows autonome (zip)">📦 Publier</button>
</nav>
{% endif %}
</div>
<main class="content {% block content_class %}{% endblock %}">
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<!-- À L'INTÉRIEUR de <main> (et pas à côté de #pageChrome) : pjax.js ne
remplace jamais que #pageChrome et main.content d'une navigation à
l'autre (voir swapDocument() dans pjax.js) — posé ailleurs, ce modal
serait bien présent dans le HTML renvoyé par le serveur juste après
l'inscription, mais jamais copié dans le DOM réellement affiché
puisque la redirection qui suit la confirmation de la 2FA passe par
une navigation pjax (fetch), pas un vrai rechargement de page. -->
<div class="modal is-active" id="recoveryCodesModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Tes codes de récupération</h2>
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
<div class="recoveryCodesGrid">
{% for code in recovery_codes %}
<code class="recoveryCode">{{ code }}</code>
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
</div>
</div>
</div>
{% endif %}
{% if game is defined and game %}
<!-- Même remarque que pour le modal des codes de récupération ci-dessus : à L'INTÉRIEUR
de <main> pour que pjax.js le remplace (et rejoue son script JS, voir
runScriptsIn()) à chaque navigation entre pages d'un même jeu — posé
dans #pageChrome (comme le bouton "📦 Publier" qui l'ouvre), son
script ne serait rejoué qu'au tout premier chargement complet de la
page. -->
<div class="modal" id="publishModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Publier « {{ game.name }} »</h2>
<p class="hint mb-4">Construit un exécutable Windows autonome (Python + serveur embarqués) : dézippé puis lancé en double-cliquant sur <code>run.bat</code>, le jeu se joue hors connexion, sans rien installer.</p>
<div id="publishProgress">
<p class="hint" id="publishStepText">Préparation du jeu...</p>
<div class="passwordStrengthBar" style="margin-top:10px;"><div class="passwordStrengthFill strong" id="publishProgressFill" style="width:100%; animation:forgePublishPulse 1.2s ease-in-out infinite;"></div></div>
</div>
<div id="publishError" class="notification is-danger" style="display:none;"></div>
<div id="publishDone" class="notification is-success" style="display:none;">✅ Téléchargement lancé — dézippe le fichier puis double-clique sur <code>run.bat</code>.</div>
<div class="field mt-4" style="display:flex; justify-content:flex-end;">
<button type="button" class="button is-small" onclick="window.forgeClosePublishModal()">Fermer</button>
</div>
</div>
</div>
</div>
<style>
@keyframes forgePublishPulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
</style>
<script>
(function () {
var modal = document.getElementById('publishModal');
var stepText = document.getElementById('publishStepText');
var progressBox = document.getElementById('publishProgress');
var errorBox = document.getElementById('publishError');
var doneBox = document.getElementById('publishDone');
var steps = ["Préparation du jeu...", "Copie des données...", "Compression du dossier..."];
var stepTimer = null;
window.forgeOpenPublishModal = function () {
var stepIndex = 0;
stepText.textContent = steps[0];
progressBox.style.display = '';
errorBox.style.display = 'none';
doneBox.style.display = 'none';
modal.classList.add('is-active');
stepTimer = setInterval(function () {
stepIndex = (stepIndex + 1) % steps.length;
stepText.textContent = steps[stepIndex];
}, 1200);
var filename = "{{ game.slug }}.zip";
fetch("{{ url_for('publish_game', slug=game.slug) }}", { method: 'POST' })
.then(function (resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var disposition = resp.headers.get('Content-Disposition') || '';
var match = /filename="?([^";]+)"?/.exec(disposition);
if (match) filename = match[1];
return resp.blob();
})
.then(function (blob) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
doneBox.style.display = '';
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
})
.catch(function (err) {
clearInterval(stepTimer);
progressBox.style.display = 'none';
errorBox.style.display = '';
errorBox.textContent = "Échec de la publication (" + err.message + "). Réessaie, ou vérifie ta connexion internet (nécessaire uniquement lors de la toute première publication sur ce poste).";
});
};
window.forgeClosePublishModal = function () {
modal.classList.remove('is-active');
if (stepTimer) clearInterval(stepTimer);
};
})();
</script>
{% endif %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='pjax.js') }}"></script>
+5
View File
@@ -3,6 +3,11 @@
{% block content_class %}content-wide{% endblock %}
{% block content %}
<div class="dashPanelHeader">
<h2 class="title is-4 mb-0">{{ game.name }}</h2>
<span class="hint">projects/{{ game.slug }}/</span>
</div>
<!-- Une seule fenêtre, un onglet par catégorie (même principe que les
onglets "Écran / Logique / Timeline" de l'éditeur d'écran, voir
.builderTabs/.builderTabBtn/.builderTabPanel dans style.css) — pour
+4 -1
View File
@@ -3,7 +3,10 @@
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content %}
<h1 class="title">Mes jeux</h1>
<div class="dashPanelHeader">
<h2 class="title is-4 mb-0">Mes jeux</h2>
<span class="forge-badge">{{ games|length }} jeu{{ 's' if games|length != 1 else '' }}</span>
</div>
<div class="twoCol">
<div class="colLeft">
+89 -35
View File
@@ -4,12 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='branding/forge-favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"><style>
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}"><style>
body{ margin:0; background:#000; }
/* ---------- Zone de jeu à proportion fixe (voir #playFrame) ----------
Chaque écran est conçu avec un format précis (Portrait/Paysage/Carré,
@@ -40,7 +40,7 @@
display:flex; align-items:center; justify-content:center;
}
.playElement input{ width:90%; height:70%; box-sizing:border-box; border:1px solid #2b3140; border-radius:4px; padding:0 8px; }
#emptyState{ color:#8b93a7; font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; }
#emptyState{ color:var(--forge-text-muted, #8b95a7); font-family:system-ui,sans-serif; text-align:center; padding-top:40vh; }
/* 3.3 (Confort) — surbrillance générique dynamique : liseré clignotant,
posé/enlevé par une action "Modifier un élément → Surbrillance",
applicable à n'importe quel élément (pas seulement les boutons). */
@@ -265,28 +265,39 @@
}
// Trouve, pour un élément et un type de déclencheur ("clic"/"soumission"),
// le nœud Déclencheur qui lui est rattaché — c'est le point d'entrée du
// graphe à exécuter. On cherche dans TOUS les écrans (pas seulement
// l'écran courant) : bindClicks() attache les gestionnaires une seule
// 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) {
// TOUS les nœuds Déclencheur qui lui sont rattachés — ce sont les points
// d'entrée des graphes à exécuter. Renvoie un TABLEAU (jamais un seul
// nœud) : rien n'empêche de poser plusieurs déclencheurs indépendants
// "Au clic" sur le MÊME élément (ex. l'un ouvre une ligne de Répéteur,
// l'autre éteint une surbrillance) — un seul clic doit alors exécuter
// CHACUN de ces graphes, pas seulement le premier trouvé (régression
// vécue : un second déclencheur posé sur un élément qui en avait déjà un
// restait silencieusement inerte, aucune erreur, juste jamais exécuté).
//
// On cherche dans TOUS les écrans (pas seulement l'écran courant) :
// bindClicks() attache les gestionnaires une seule 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 de CHAQUE 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 findTriggerNodess(elementId, event) {
const found = [];
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 { node: found, screenId: screenIdKey };
gameData.flows[screenIdKey].nodes.forEach(function(n) {
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
found.push({ node: n, screenId: screenIdKey });
}
});
}
return null;
return found;
}
// Ré-attache les gestionnaires de clic sur chaque élément — appelé au
@@ -313,8 +324,8 @@
function bindClicks() {
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
const elementId = parseInt(elDiv.dataset.elementId, 10);
const trigger = findTriggerNode(elementId, 'clic');
if (trigger) {
const triggers = findTriggerNodess(elementId, 'clic');
if (triggers.length) {
// Garde-fou anti-doublon : refreshRuntimeData() rappelle bindClicks()
// après chaque changement de donnée, mais un élément qui n'est ni un
// Répéteur ni une Jauge garde le MÊME nœud DOM d'un rafraîchissement
@@ -337,7 +348,9 @@
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.node.id, trigger.screenId);
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
// seulement le premier (voir le commentaire de findTriggerNodess).
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
});
}
});
@@ -354,12 +367,15 @@
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
if (elDiv.dataset.hoverTriggerBound) return;
const elementId = parseInt(elDiv.dataset.elementId, 10);
const enterTrigger = findTriggerNode(elementId, 'survol');
const leaveTrigger = findTriggerNode(elementId, 'fin_survol');
if (!enterTrigger && !leaveTrigger) return;
const enterTriggers = findTriggerNodess(elementId, 'survol');
const leaveTriggers = findTriggerNodess(elementId, 'fin_survol');
if (!enterTriggers.length && !leaveTriggers.length) return;
elDiv.dataset.hoverTriggerBound = '1';
if (enterTrigger) elDiv.addEventListener('mouseenter', () => runFlowFrom(enterTrigger.node.id, enterTrigger.screenId));
if (leaveTrigger) elDiv.addEventListener('mouseleave', () => runFlowFrom(leaveTrigger.node.id, leaveTrigger.screenId));
// Exécute CHAQUE déclencheur trouvé, pas seulement le premier — voir
// le commentaire de findTriggerNodess (plusieurs déclencheurs
// indépendants sur le même élément/événement doivent tous s'exécuter).
if (enterTriggers.length) elDiv.addEventListener('mouseenter', () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
if (leaveTriggers.length) elDiv.addEventListener('mouseleave', () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId)));
});
}
@@ -605,7 +621,20 @@
const found = [];
root.querySelectorAll('[data-element-id]').forEach(function(node) {
const sib = node.nextSibling;
if (sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1) {
const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
// Un élément actuellement CACHÉ par une condition de
// visibilité (voir render_element_html.py) n'a pas de
// commentaire marqueur à sa suite — sa balise porte
// directement la classe "visibilityGated" à la place. Sans
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
// n'était jamais retrouvé ici (seul le sens caché->visible
// l'était, via le commentaire) : son ancien nœud visible
// restait donc affiché indéfiniment à côté du nouveau,
// maintenant visible, qui le remplace normalement (ex. une
// icône "enveloppe fermée" jamais masquée en même temps que
// "enveloppe ouverte" qui apparaît après avoir cliqué).
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
if (hasCommentMarker || isHiddenPlaceholder) {
found.push(node);
}
});
@@ -779,6 +808,13 @@
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
// Notification pure (voir screens/custom_events/) : ne fait que
// signaler l'événement, jamais choisir un élément — c'est à
// l'écouteur (déclencheur → condition → action) de décider quoi
// faire, avec ses propres réglages habituels.
window.dispatchGameEvent(node.target_custom_event_id);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
@@ -962,10 +998,10 @@
// 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.
// flowScreenId : l'écran PROPRIÉTAIRE du graphe à exécuter — par défaut
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNode)
// l'écran affiché, mais peut être un écran-MODÈLE (voir findTriggerNodes)
// 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
// paramètre, un tel déclencheur était bien retrouvé (findTriggerNodes
// 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) {
@@ -995,6 +1031,24 @@
step(nodeId);
}
// Déclenche un événement personnalisé (voir screens/custom_events/,
// action "declencher_evenement") : une pure NOTIFICATION, sans aucun
// paramètre — scan GLOBAL de gameData.flows, même principe que
// findTriggerNodes()/runScreenShowTriggers() ci-dessus, puisqu'un
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur
// N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché.
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
// suite condition/action, avec ses réglages habituels — cible fixe,
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
window.dispatchGameEvent = function(eventId) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
}
};
// L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
// fait sur l'écran courant (window.currentScreenId), qui doit donc déjà
+141 -9
View File
@@ -3,12 +3,12 @@
{% block content_class %}content-builder{% endblock %}
{% block body_class %}builderBody{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/fonts/fonts.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/animate.min.css') }}">
{% endblock %}
{% block content %}
{% if screen.is_template %}
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
<p class="hint" style="background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:10px 14px; margin:10px 0;">
🧩 Contenu d'un élément de jeu réutilisable.
</p>
{% endif %}
@@ -172,6 +172,7 @@
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
<button type="button" class="builderTabBtn" data-tab="events" onclick="switchBuilderTab('events')">📣 Événements</button>
</div>
<div class="builderTabPanel active" data-tab-panel="screen">
@@ -216,6 +217,16 @@
</select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
<div id="nf-trigger-event-row" style="display:none;">
<label>Événement personnalisé<select id="nf-trigger-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select></label>
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% endif %}
</div>
</div>
<div id="nodeFormCondition" style="display:none;">
@@ -371,6 +382,23 @@
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
<div id="nf-action-event-fields" style="display:none;">
{% if not custom_events %}
<p class="hint">Aucun événement pour l'instant — <a href="javascript:void(0)" onclick="switchBuilderTab('events'); return false;">crées-en un dans l'onglet Événements</a>.</p>
{% else %}
<!-- Déclencher un événement = notifier, rien d'autre : aucun
élément/ligne à choisir ici, c'est à l'ÉCOUTEUR (nœud
Déclencheur "Sur un événement personnalisé", plus bas dans
son propre graphe) de décider quoi faire, avec ses propres
réglages habituels — voir screens/custom_events/. -->
<select id="nf-action-custom-event">
{% for ev in custom_events %}
<option value="{{ ev.id }}">{{ ev.name }}</option>
{% endfor %}
</select>
{% endif %}
</div>
</div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
@@ -470,6 +498,66 @@
<div id="animTracks" class="animTracks"></div>
</div>
</div>
<!-- Onglet Événements : entité GAME-WIDE (pas propre à cet écran, voir
db/custom_events/) mais gérée depuis ici plutôt que le tableau de
bord — pour rester au plus près de l'endroit où on câble des
écouteurs/déclencheurs (nœuds Déclencheur/Action de la Logique de
la scène ci-dessus). Présent aussi sur un écran-modèle, comme les
autres onglets. -->
<div class="builderTabPanel" id="eventsTabPanel" data-tab-panel="events">
<div class="dashPanelHeader">
<h2>Événements personnalisés</h2>
<button type="button" class="button is-small primary" onclick="toggleDashCreate('events')">+ Créer un événement</button>
</div>
<form method="post" action="{{ url_for('custom_event_new', slug=game.slug) }}" class="dashCreateBar" id="dashCreate-events" style="display:none;">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<input type="text" name="name" placeholder="NOM (ex. NEED_ACTION)" required autocomplete="off">
<input type="text" name="description" placeholder="Description (optionnel)">
<button type="submit" class="primary">Créer</button>
</form>
{% if custom_events %}
{% for ev in custom_events %}
<form method="post" action="{{ url_for('custom_event_edit', slug=game.slug, event_id=ev.id) }}" id="eventEditForm{{ ev.id }}">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
</form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead><tr><th>Nom</th><th>Description</th><th>Utilisé par</th><th></th></tr></thead>
<tbody>
{% for ev in custom_events %}
<tr>
<td><code>{{ ev.name }}</code></td>
<td><input type="text" form="eventEditForm{{ ev.id }}" name="description" value="{{ ev.description or '' }}" placeholder="Description" aria-label="Description de « {{ ev.name }} »"></td>
<td>
{% set usages = custom_event_usages.get(ev.id, []) %}
{% if usages %}
{% for u in usages %}
<a href="{{ url_for('screen_edit', slug=game.slug, screen_id=u.screen_id) }}" class="linkLikeButton" title="{{ 'Écouté ici' if u.role == 'ecoute' else 'Déclenché ici' }}">{{ '🔔' if u.role == 'ecoute' else '📣' }} {{ u.screen_name }}{{ ' (modèle)' if u.is_template else '' }}</a>{{ ', ' if not loop.last else '' }}
{% endfor %}
{% else %}
<span class="hint">Inutilisé</span>
{% endif %}
</td>
<td class="fieldsTableActions">
<button type="submit" form="eventEditForm{{ ev.id }}" class="button is-small" title="Enregistrer">💾</button>
<form method="post" action="{{ url_for('custom_event_delete', slug=game.slug, event_id=ev.id) }}"
onsubmit="return confirm('Supprimer l\'événement « {{ ev.name }} » ? Cela supprime aussi tous ses écouteurs et déclencheurs, sur toutes les scènes et tous les modèles.');">
<input type="hidden" name="screen_id" value="{{ screen.id }}">
<button type="submit" class="button is-small is-danger" title="Supprimer">🗑️</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucun événement pour l'instant — crée le premier ci-dessus, puis pose un nœud "Sur un événement personnalisé" (Déclencheur) ou "Déclencher un événement" (Action) dans la Logique de la scène.</p>
{% endif %}
</div>
</div>
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
@@ -522,7 +610,7 @@
</header>
<section class="modal-card-body">
<p class="hint">Cette action est irréversible.</p>
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #e2685f);"></p>
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #f04438);"></p>
</section>
<footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;">
<button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button>
@@ -2087,6 +2175,7 @@ var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
var CUSTOM_EVENTS_MAP = {{ custom_events_map_json|safe }};
var flowPendingPort = null; // {nodeId, port} en attente de connexion
@@ -2095,6 +2184,10 @@ function flowUrl(path) { return "/game/{{ game.slug }}" + path; }
function nodeLabel(node) {
if (node.node_type === 'trigger') {
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
if (node.trigger_event === 'evenement') {
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
}
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
}
@@ -2147,6 +2240,12 @@ function nodeLabel(node) {
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') {
// Notification pure : rien d'autre à afficher que le nom de
// l'événement (voir screens/custom_events/) — aucun élément/ligne
// n'est jamais transmis au déclenchement.
var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
}
return base;
}
@@ -2324,10 +2423,16 @@ function openNodeForm(type) {
}
function toggleFlowTriggerFields() {
var isAffichage = document.getElementById('nf-trigger-event').value === 'affichage';
document.getElementById('nf-trigger-element-row').style.display = isAffichage ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = isAffichage ? 'none' : 'block';
var eventVal = document.getElementById('nf-trigger-event').value;
var isAffichage = eventVal === 'affichage';
// "Sur un événement personnalisé" ne concerne ni un élément précis ni
// l'écran entier : c'est un scan global (voir dispatchGameEvent() dans
// play.html), le seul réglage propre à ce nœud est QUEL événement.
var isCustomEvent = eventVal === 'evenement';
document.getElementById('nf-trigger-element-row').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = (isAffichage || isCustomEvent) ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
}
function closeNodeForm() {
@@ -2427,7 +2532,7 @@ function renderFlowConditionClauses() {
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
var isVariable = clause.source === 'variable';
return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;">' +
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
@@ -2491,12 +2596,14 @@ function toggleFlowActionFields() {
var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields');
var isOnglet = typeSel.value === 'activer_onglet';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
@@ -2556,7 +2663,9 @@ function submitNodeForm() {
if (currentNodeFormType === 'trigger') {
payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event !== 'affichage') {
if (payload.trigger_event === 'evenement') {
payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10);
} else if (payload.trigger_event !== 'affichage') {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
} else if (currentNodeFormType === 'condition') {
@@ -2639,6 +2748,11 @@ function submitNodeForm() {
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') {
// Notification pure : aucun élément/ligne à transmettre (voir
// screens/custom_events/) — c'est à l'écouteur de décider quoi
// faire, avec ses propres réglages habituels.
payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
}
}
@@ -2671,6 +2785,24 @@ function switchBuilderTab(tab) {
if (tab === 'flow') requestAnimationFrame(drawFlowEdges);
}
function toggleDashCreate(tab) {
var bar = document.getElementById('dashCreate-' + tab);
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
if (bar.style.display === 'flex') {
var firstInput = bar.querySelector('input, select');
if (firstInput) firstInput.focus();
}
}
// Onglet initial : ?tab=... (utilisé par les routes de CRUD des
// événements, routes/custom_events/, pour revenir directement sur
// l'onglet "Événements" après création/modification/suppression plutôt
// que de laisser deviner où cliquer — même patron que game_dashboard.html).
(function () {
var requestedTab = {{ (request.args.get('tab') or '')|tojson }};
if (requestedTab) switchBuilderTab(requestedTab);
})();
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
+4 -4
View File
@@ -86,18 +86,18 @@ def test_clip_delete_removes_it(client, game):
assert f'"id": {clip["id"]}' not in html
def test_screen_edit_exposes_timeline_panel_and_animate_css_cdn(client, game):
def test_screen_edit_exposes_timeline_panel_and_animate_css(client, game):
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "animTabPanel" in html
assert "Timeline d'animation" in html
assert "animate.css" in html
assert "animate.min.css" in html
def test_play_page_exposes_animation_runtime_and_cdn(client, game):
def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "animate.css" in html
assert "animate.min.css" in html
assert "runAnimationTimeline" in html
assert "injectCustomKeyframes" in html
+143
View File
@@ -0,0 +1,143 @@
"""Événements personnalisés (db/custom_events/, screens/custom_events/) :
un événement game-wide (ex. NEED_ACTION) écouté par un nœud Déclencheur
("trigger_event"="evenement") sur UNE scène/UN modèle, et déclenché par
un nœud Action ("action_type"="declencher_evenement") sur UNE AUTRE
scène/UN AUTRE modèle — la seule entité game-wide du moteur dont la
suppression cascade RÉELLEMENT vers les nœuds qui la référencent,
partout à la fois (voir screens/custom_events/delete_custom_event.py).
Un événement est une pure NOTIFICATION, sans aucun paramètre : le
déclencher ne fait que signaler, jamais choisir un élément — c'est à
l'ÉCOUTEUR (déclencheur → condition → action) de décider quoi faire
ensuite, avec ses propres réglages habituels (cible fixe, "Ligne
cliquée"...)."""
import json
import re
import db
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_create_event_is_idempotent_by_name(client, game):
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/events/new", data={
"name": "NEED_ACTION", "description": "Besoin d'action", "screen_id": screen_id,
})
assert resp.status_code == 302
events = db.list_custom_events(game)
assert len(events) == 1
assert events[0]["name"] == "NEED_ACTION"
# Double soumission : ne crée pas un deuxième événement du même nom.
client.post(f"/game/{game}/events/new", data={"name": "NEED_ACTION", "screen_id": screen_id})
assert len(db.list_custom_events(game)) == 1
def test_edit_event_updates_description(client, game):
screen_id = _create_screen(client, game)
event_id = db.create_custom_event(game, "NEED_ACTION", "old")
resp = client.post(f"/game/{game}/events/{event_id}/edit", data={
"description": "new", "screen_id": screen_id,
})
assert resp.status_code == 302
event = db.get_custom_event(game, event_id)
assert event["description"] == "new"
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
_add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
_add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
usages = screens.list_custom_event_usages(game)
roles = {u["role"] for u in usages[event_id]}
screen_ids = {u["screen_id"] for u in usages[event_id]}
assert roles == {"ecoute", "declenche"}
assert screen_ids == {listener_screen, dispatcher_screen}
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
)
# Le modèle (élément de jeu réutilisable) est un écran caché — voir
# screens.create_element_type — le déclencheur y est posé exactement
# comme sur une vraie scène.
screens.create_element_type(game, "Carte email", "📧")
template = next(t for t in screens.list_element_types(game) if t["name"] == "Carte email")
template_screen_id = template["template_screen_id"]
dispatcher_node_id = _add_flow_node(
client, game, template_screen_id,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False)
assert resp.status_code == 302
assert db.get_custom_event(game, event_id) is None
assert screens.get_flow_node(game, listener_node_id) is None
assert screens.get_flow_node(game, dispatcher_node_id) is None
# Les écrans/modèles eux-mêmes ne sont pas touchés, seuls les nœuds.
assert screens.get_screen(game, listener_screen) is not None
assert screens.get_screen(game, template_screen_id) is not None
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
"""templates/play.html::dispatchGameEvent()/findTriggerNode() scannent
gameData.flows (toutes les scènes ET tous les modèles à la fois) pour
retrouver un nœud par trigger_custom_event_id/target_custom_event_id —
ce test verrouille que full_game_payload() expose bien ces deux champs
intacts pour chaque écran, avec le bon type (entier, pas une chaîne),
faute de quoi une comparaison stricte "===" côté JS échouerait
silencieusement."""
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
)
payload = screens.full_game_payload(game)
flows = payload["flows"]
listener_node = next(n for n in flows[str(listener_screen)]["nodes"] if n["id"] == listener_node_id)
assert listener_node["trigger_event"] == "evenement"
assert listener_node["trigger_custom_event_id"] == event_id
dispatcher_node = next(n for n in flows[str(dispatcher_screen)]["nodes"] if n["id"] == dispatcher_node_id)
assert dispatcher_node["action_type"] == "declencher_evenement"
assert dispatcher_node["target_custom_event_id"] == event_id
+120
View File
@@ -141,3 +141,123 @@ def test_the_only_admin_account_cannot_be_deleted(client):
assert "administrateur" in resp.get_data(as_text=True)
import tests.conftest as conftest_module
assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None
def test_regenerate_recovery_codes_requires_correct_password(anon_client):
_register(anon_client, "regencodeswrong@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/regenerate-recovery-codes", data={"current_password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
finally:
_cleanup_project("regencodeswrong@example.com")
def test_regenerate_recovery_codes_replaces_the_old_ones_and_shows_them_once(anon_client):
_register(anon_client, "regencodesok@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("regencodesok@example.com")
try:
old_codes = auth.generate_recovery_codes(user["id"])
resp = anon_client.post("/profile/regenerate-recovery-codes", data={"current_password": "Sup3r$ecret!"})
html = resp.get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant.
html_again = anon_client.get("/profile").get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
# Les anciens codes ne fonctionnent plus, mais le nouveau lot,
# visible dans le modal ci-dessus, doit être réellement stocké en
# base — vérifié indirectement via verify_recovery_code : aucun des
# anciens codes ne doit plus matcher.
for old_code in old_codes:
assert not auth.verify_recovery_code(user["id"], old_code)
finally:
_cleanup_project("regencodesok@example.com")
def test_update_email_requires_correct_password(anon_client):
_register(anon_client, "emailwrongpw@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "new@example.com", "current_password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("emailwrongpw@example.com") is not None
finally:
_cleanup_project("emailwrongpw@example.com")
def test_update_email_rejects_invalid_format(anon_client):
_register(anon_client, "emailinvalid@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "not-an-email", "current_password": "Sup3r$ecret!"})
assert "invalide" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailinvalid@example.com")
def test_update_email_rejects_an_email_already_used(anon_client):
_register(anon_client, "emailtaken1@example.com")
_confirm_2fa(anon_client)
anon_client.post("/logout")
_register(anon_client, "emailtaken2@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "emailtaken1@example.com", "current_password": "Sup3r$ecret!"})
assert "existe déjà" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailtaken1@example.com")
_cleanup_project("emailtaken2@example.com")
def test_update_email_renames_the_user_project_folder(anon_client):
import os
_register(anon_client, "oldmail@example.com")
_confirm_2fa(anon_client)
old_user = auth.get_user_by_email("oldmail@example.com")
old_slug = old_user["project_slug"]
assert os.path.isdir(db.game_dir(old_slug))
try:
resp = anon_client.post("/profile/update-email", data={"email": "newmail@example.com", "current_password": "Sup3r$ecret!"})
assert "mise à jour" in resp.get_data(as_text=True)
assert auth.get_user_by_email("oldmail@example.com") is None
new_user = auth.get_user_by_email("newmail@example.com")
assert new_user is not None
new_slug = new_user["project_slug"]
assert new_slug == db.slugify("newmail@example.com")
assert not os.path.isdir(db.game_dir(old_slug))
assert os.path.isdir(db.game_dir(new_slug))
# La connexion se fait désormais avec la nouvelle adresse.
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "newmail@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
finally:
_cleanup_project("oldmail@example.com")
_cleanup_project("newmail@example.com")
def test_update_email_for_an_admin_does_not_touch_any_project_folder(client, game):
"""`client`/`game` (conftest.py) : compte admin, pas de project_slug
dédié — changer son adresse ne doit renommer aucun dossier. L'admin de
test est PARTAGÉ par toute la session de tests (voir conftest.py) :
son adresse est restaurée après coup pour ne pas perturber les autres
tests qui s'appuient dessus."""
import tests.conftest as conftest_module
try:
resp = client.post("/profile/update-email", data={"email": "newadminmail@example.com", "current_password": "Test1234!"})
assert "mise à jour" in resp.get_data(as_text=True)
user = auth.get_user_by_id(conftest_module._TEST_ADMIN_ID)
assert user["email"] == "newadminmail@example.com"
assert user["project_slug"] is None
assert db.game_meta(game) is not None
finally:
auth.update_email(conftest_module._TEST_ADMIN_ID, "admin@pytest.local")
+91
View File
@@ -0,0 +1,91 @@
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
routes/publish/publish_game.py et publish/build_package.py). Le vrai
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
(fichiers présents, structure), jamais un vrai lancement du serveur
embarqué (nécessite un environnement Windows réel, voir le plan)."""
import os
import zipfile
import pytest
import publish.build_package as build_package
from tests.test_auth import anon_client # noqa: F401
@pytest.fixture
def fake_vendor(tmp_path, monkeypatch):
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
python_dir = tmp_path / "python-embed-amd64"
python_dir.mkdir()
(python_dir / "python.exe").write_bytes(b"fake-exe")
pylibs_dir = tmp_path / "pylibs"
pylibs_dir.mkdir()
(pylibs_dir / "flask").mkdir()
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
def _fake_ensure_vendor_ready():
return str(python_dir), str(pylibs_dir)
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
return python_dir, pylibs_dir
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
zip_path, game_name = build_package.build_game_zip(game)
try:
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
for expected in [
"run.bat", "player_app.py",
f"projects/{game}/game.db",
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
"templates/play.html",
"static/style.css", "static/csrf_fetch.js",
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
]:
assert expected in names, f"{expected} manquant du zip"
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
zip_path, _ = build_package.build_game_zip(game)
try:
with zipfile.ZipFile(zip_path) as zf:
player_app_src = zf.read("player_app.py").decode("utf-8")
assert f'SLUG = "{game}"' in player_app_src
assert "_SLUG_PLACEHOLDER" not in player_app_src
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
resp = client.post(f"/game/{game}/publish")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK" # signature d'un fichier zip
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
le projet d'un autre (même vérification que /game/<slug> lui-même,
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _cleanup_project
_register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403
finally:
_cleanup_project("publishisolation@example.com")
+98
View File
@@ -0,0 +1,98 @@
"""Régression : après une action "Modifier une donnée" qui fait BASCULER
une condition de visibilité (ex. une icône "enveloppe fermée" visible
quand is_opened=faux, une "enveloppe ouverte" visible quand is_opened=vrai),
templates/play.html::refreshRuntimeData() ne retrouvait, dans le HTML
fraîchement régénéré, que l'élément qui REDEVIENT visible (repéré via le
commentaire "<!--visibilityGated-->" posé après son contenu réel par
_mark(), voir render_element_html.py) — jamais celui qui REDEVIENT
CACHÉ, dont le placeholder ("<div class=\"visibilityGated\" ...
style=\"display:none;\"></div>") n'a pas de commentaire à sa suite, juste
une classe CSS sur lui-même. Résultat : l'ancien élément visible restait
affiché indéfiniment à côté du nouveau qui apparaît en plus (les deux
enveloppes en même temps).
Ces tests-ci ne peuvent pas simuler le DOM d'un navigateur (refreshRuntimeData
est du JS) — ils verrouillent seulement le CONTRAT côté serveur dont
dépend le correctif JS (findCommentMarkedDescendants, voir play.html) :
un élément CACHÉ par condition de visibilité doit porter la classe
"visibilityGated" directement sur sa balise, un élément VISIBLE sous
condition doit porter le commentaire juste après. Voir aussi
tests/test_data_binding_refresh_marker.py pour le marqueur "dataBound"."""
import re
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_icon_with_visibility(client, slug, screen_id, def_id, field_name, expected):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "icone"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{slug}/elements/{el_id}/save", data={
"ctrl_visibilite_cond_mode": "objet",
"ctrl_visibilite_cond_definition_id": str(def_id),
"ctrl_visibilite_cond_champ": field_name,
"ctrl_visibilite_cond_operateur": "egal",
"ctrl_visibilite_cond_valeur": expected,
})
return el_id
def test_hidden_element_carries_the_class_marker_not_a_comment(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Email", "field_name[]": ["is_opened"],
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
"field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"})
screen_id = _create_screen(client, game)
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
payload = client.get(f"/game/{game}/runtime-payload").get_json()
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
closed_html = elements[closed_id]["rendered_html"]
open_html = elements[open_id]["rendered_html"]
# is_opened=faux : l'enveloppe "fermée" est visible (marqueur en
# commentaire, après son vrai contenu) ; l'enveloppe "ouverte" est
# cachée (marqueur en CLASSE, directement sur le placeholder).
assert "<!--visibilityGated-->" in closed_html
assert f'data-element-id="{closed_id}"' in closed_html
assert "display:none" not in closed_html
assert 'class="visibilityGated"' in open_html
assert "display:none" in open_html
assert f'data-element-id="{open_id}"' in open_html
assert "<!--visibilityGated-->" not in open_html
def test_toggling_the_field_swaps_which_element_carries_which_marker(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Email2", "field_name[]": ["is_opened"],
"field_type[]": ["booleen"], "field_relation[]": [""], "field_required[]": ["0"],
"field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
resp = client.post(f"/game/{game}/objects/{def_id}/data/new", data={"is_opened": "0"}, follow_redirects=False)
screen_id = _create_screen(client, game)
closed_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Non")
open_id = _add_icon_with_visibility(client, game, screen_id, def_id, "is_opened", "Oui")
import db
rows = db.list_rows(game, db.get_definition(game, def_id))
row_id = rows[0]["id"]
db.update_row(game, db.get_definition(game, def_id), row_id, {"is_opened": "on"})
payload = client.get(f"/game/{game}/runtime-payload").get_json()
elements = {e["id"]: e for e in payload["screens"][0]["elements"]}
# is_opened=vrai maintenant : les rôles s'inversent complètement, aucun
# des deux ne doit garder son ancien marqueur.
assert 'class="visibilityGated"' in elements[closed_id]["rendered_html"]
assert "<!--visibilityGated-->" in elements[open_id]["rendered_html"]