diff --git a/.gitignore b/.gitignore
index 78597e9c..3f4e6ab9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ __pycache__/
.venv/
venv/
env/
+Bug/
# Variables d'environnement / secrets locaux
.env
diff --git a/screens/clause_list_codec.py b/screens/clause_list_codec.py
new file mode 100644
index 00000000..11eded13
--- /dev/null
+++ b/screens/clause_list_codec.py
@@ -0,0 +1,45 @@
+import json
+
+# Partagé par control_value.py (widgets) et filter_repeater_rows.py
+# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne
+# jamais introduire de dépendance circulaire entre ces deux packages.
+
+
+def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None):
+ """Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un
+ attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres
+ fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et
+ legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est
+ absent — pour ne rien casser sur les éléments créés avant l'ajout des
+ clauses illimitées (aucune migration de données nécessaire, la
+ conversion se fait à la volée, ici, à la lecture). Renvoie toujours
+ (clauses, combinateur), combinateur valant "et" par défaut."""
+ raw = attrs.get(clauses_attr)
+ clauses = None
+ if raw:
+ try:
+ parsed = json.loads(raw)
+ if isinstance(parsed, list):
+ clauses = parsed
+ except (TypeError, ValueError):
+ clauses = None
+ if clauses is None and legacy_prefix:
+ clauses = []
+ for suffix in ("", "2"):
+ champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip()
+ if champ:
+ clauses.append({
+ "champ": champ,
+ "operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal",
+ "valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "",
+ })
+ combinateur = attrs.get(combinator_attr) or "et"
+ return clauses or [], combinateur
+
+
+def encode_clauses(clauses):
+ """Sérialise une liste de clauses en JSON, en retirant celles sans nom
+ de champ (une ligne ajoutée puis jamais remplie) — "" si la liste
+ nettoyée est vide, pour ne rien écrire de superflu en base."""
+ cleaned = [c for c in clauses if (c.get("champ") or "").strip()]
+ return json.dumps(cleaned) if cleaned else ""
diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py
index 111093ca..e1e99b1b 100644
--- a/screens/elements/add_element.py
+++ b/screens/elements/add_element.py
@@ -6,6 +6,7 @@ from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
+from ..element_types.is_overlay_only import _is_overlay_only_element_type
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
@@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
+ if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
+ # Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
+ # de dialogue" (render_element_html.py décide alors, au rendu, de
+ # faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
+ # démarre masqué par défaut, exactement comme une superposition
+ # posée directement (voir default_style_for_widget.py) — sinon un
+ # dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
+ # sa pose, avant même qu'une action ne l'affiche.
+ style["display"] = "none"
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
diff --git a/screens/elements/save_element_controls.py b/screens/elements/save_element_controls.py
index 2b16837b..2abb9587 100644
--- a/screens/elements/save_element_controls.py
+++ b/screens/elements/save_element_controls.py
@@ -4,6 +4,7 @@ import db
from ..widgets.widget_meta import widget_meta
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
+from ..clause_list_codec import encode_clauses
from .get_element import get_element
@@ -53,6 +54,32 @@ def save_element_controls(slug, element_id, form):
toggle_groups[name].add(control["token"])
continue
+ if ctype == "clause_list":
+ # Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
+ # tableau par colonne ("field_name_champ[]"/"_operateur[]"/
+ # "_valeur[]"), reconstituées ici en une liste de clauses par
+ # position (zip) — même principe que field_name[]/field_type[]
+ # pour les champs d'un objet (routes/objects/parse_field_rows.py).
+ champs = form.getlist(field_name + "_champ[]")
+ operateurs = form.getlist(field_name + "_operateur[]")
+ valeurs = form.getlist(field_name + "_valeur[]")
+ clauses = [
+ {"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
+ "valeur": valeurs[i] if i < len(valeurs) else ""}
+ for i, champ in enumerate(champs)
+ ]
+ encoded = encode_clauses(clauses)
+ if encoded:
+ attributes[control["clauses_attr"]] = encoded
+ else:
+ attributes.pop(control["clauses_attr"], None)
+ combinateur = form.get(field_name + "_combinateur") or "et"
+ if combinateur == "ou":
+ attributes[control["combinator_attr"]] = combinateur
+ else:
+ attributes.pop(control["combinator_attr"], None)
+ continue
+
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
diff --git a/screens/flow/constants.py b/screens/flow/constants.py
index 89050b47..3460ca17 100644
--- a/screens/flow/constants.py
+++ b/screens/flow/constants.py
@@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = {
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
# comment les combiner. Absents => comportement legacy (une seule clause).
"cond_clauses", "cond_combinator",
+ # Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir
+ # ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut
+ # "objet" ou "variable" ; absent => "objet", comportement historique).
+ "cond_source", "cond_variable", "cond_variable_chemin",
"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",
diff --git a/screens/flow/ensure_flow_schema.py b/screens/flow/ensure_flow_schema.py
index a796fe91..155f2430 100644
--- a/screens/flow/ensure_flow_schema.py
+++ b/screens/flow/ensure_flow_schema.py
@@ -56,5 +56,21 @@ def ensure_flow_schema(slug):
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
+ if "cond_source" not in cols:
+ # Un nœud Condition peut désormais tester soit un champ d'objet
+ # (comportement historique, cond_source absent ou "objet"), soit
+ # une VARIABLE GLOBALE (cond_source="variable" — cond_variable :
+ # son nom ; cond_variable_chemin : chemin optionnel ".champ"/
+ # "[index]" si elle est de type objet/tableau, même syntaxe que
+ # "Chemin dans la variable" de la Condition de visibilité — voir
+ # visibility_condition_controls.py). Chaque clause de cond_clauses
+ # (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté
+ # CLIENT (templates/play.html, evaluateConditionClause) contre
+ # gameData.variables, exposé par full_game_payload.py.
+ conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT")
+ if "cond_variable" not in cols:
+ 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")
conn.commit()
conn.close()
diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py
index 69fd2894..cad48553 100644
--- a/screens/payload/full_game_payload.py
+++ b/screens/payload/full_game_payload.py
@@ -67,7 +67,16 @@ def full_game_payload(slug):
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
+ # Instantané des variables globales, pour qu'un nœud Condition puisse en
+ # tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
+ # {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
+ # SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de
+ # ça. refreshRuntimeData() récupère un payload entier (donc des
+ # variables à jour) après toute action qui en modifie une.
+ variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)}
+
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
+ "variables": variables,
}
diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py
index 6aca7715..8b0d025a 100644
--- a/screens/rendering/filter_repeater_rows.py
+++ b/screens/rendering/filter_repeater_rows.py
@@ -3,6 +3,8 @@ import re
import db
+from ..clause_list_codec import decode_clauses
+
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
@@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type):
return False
-def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
+def _clause_matches(slug, definition, row, clause):
+ field_name = (clause.get("champ") or "").strip()
if not field_name:
- return rows
- expected = _resolve_filter_value(slug, raw_value or "")
+ return True
+ operator = clause.get("operateur") or "egal"
+ expected = _resolve_filter_value(slug, clause.get("valeur") or "")
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = _field_column(definition, field_name)
- return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
+ return _compare(row.get(col), operator, expected, field_type)
+
+
+def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
+ """Ne garde, parmi les lignes, que celles qui satisfont la liste de
+ clauses données (un nombre quelconque), combinées entre elles par ET
+ (toutes vraies) ou par OU (au moins une vraie) selon `combinator`
+ ("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
+ reconstruites depuis _filtre_champ/_filtre2_champ, voir
+ filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
+ Titre (clauses illimitées, voir resolve_bound_row.py)."""
+ if not clauses:
+ return rows
+ if combinator == "ou":
+ return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)]
+ return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)]
def filter_repeater_rows(slug, definition, rows, attrs):
@@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs):
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
par exemple, que le niveau du parcours en cours (2 conditions : le bon
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
- rows = _apply_one_filter(
- slug, definition, rows,
- (attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"),
- )
- rows = _apply_one_filter(
- slug, definition, rows,
- (attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"),
- )
- return rows
+ clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
+ return filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py
index bf8dcd3e..1cf430e9 100644
--- a/screens/rendering/render_element_html.py
+++ b/screens/rendering/render_element_html.py
@@ -117,19 +117,35 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
# générique "conteneur" (add_element.py, "default_widget") — utile
# pour la plupart des modèles, mais QUAND le modèle entier n'est
- # qu'une "Superposition / boîte de dialogue", cette enveloppe
- # (fond, bordure, position normale sur le canevas — voir
- # widgets/registry.py "conteneur") resterait visible EN PERMANENCE
- # à l'endroit où l'exemplaire a été déposé, alors que la
- # superposition à l'intérieur gère déjà entièrement sa propre
- # apparence et son propre masquage (position:fixed plein écran,
- # démarre masquée) — vécu comme "un conteneur vide apparaît sur la
- # scène, pas la boîte de dialogue" (en réalité la boîte de dialogue
- # existe bien, juste masquée comme prévu ; c'est le conteneur
- # AUTOUR qui n'aurait jamais dû être visible). On court-circuite
- # donc entièrement l'enveloppe et on renvoie directement le
- # contenu du modèle.
- return _mark(child_html)
+ # qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
+ # de ce widget (widgets/registry.py "conteneur") lui donnait un
+ # fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
+ # déposé, alors que la superposition à l'intérieur gère déjà
+ # entièrement sa propre apparence et son propre masquage
+ # (position:fixed plein écran, démarre masquée) — vécu comme "un
+ # conteneur vide apparaît sur la scène, pas la boîte de dialogue"
+ # (en réalité la boîte de dialogue existe bien, juste masquée comme
+ # prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
+ # propre apparence visuelle).
+ #
+ # On ne court-circuite PLUS entièrement cette enveloppe (un essai
+ # précédent le faisait, en renvoyant `child_html` directement) :
+ # cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
+ # précis (id=el["id"], distinct de l'id interne du modèle,
+ # ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
+ # CET exemplaire est, par ex., 77) — une action "Modifier un
+ # élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
+ # lui-même) cible cet id d'exemplaire, jamais celui, interne et
+ # partagé par tous les exemplaires, du modèle. La court-circuiter
+ # entièrement faisait disparaître cet id du DOM, rendant
+ # IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
+ # logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
+ # visibilité de la modale depuis la scène"). On garde donc la
+ # balise (avec son id), mais SANS la classe "box" du widget
+ # générique — juste une balise transparente, invisible tant
+ # qu'elle ne porte ni fond ni bordure."""
+ attrs.pop("class", None)
+ return _mark(f"
{child_html}
")
if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()]
@@ -154,12 +170,29 @@ def _render_element_type_children(el, slug, ctx):
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
- générique "conteneur" de l'exemplaire doit être court-circuitée)."""
+ générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
+ apparence visuelle)."""
template_tree = _load_template_tree(slug, el["element_type_id"])
if not template_tree:
return None, False
+ is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
+ if is_overlay_only:
+ # Le masquage de CET EXEMPLAIRE est entièrement délégué à
+ # l'enveloppe (voir l'appelant : add_element.py la fait démarrer
+ # masquée par défaut, exactement comme la superposition elle-même)
+ # — la superposition INTERNE au modèle ne doit donc plus appliquer
+ # SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
+ # ailleurs (sinon double masquage : rendre l'enveloppe visible
+ # depuis la scène qui l'accueille resterait sans aucun effet
+ # visible tant que la superposition interne, elle, reste "Masqué"
+ # dans le modèle). On retire donc "display" de sa copie de style
+ # avant rendu (copie éphémère, jamais réenregistrée) — seul le
+ # display:flex de base (render_overlay.py) s'applique alors.
+ overlay_root = dict(template_tree["top"][0])
+ overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
+ template_tree["top"][0] = overlay_root
html = "".join(
render_element_html(c, slug, template_tree["children_map"], ctx)
for c in template_tree["top"]
)
- return html, _is_overlay_only_element_type(slug, el["element_type_id"])
+ return html, is_overlay_only
diff --git a/screens/rendering/resolve_bound_row.py b/screens/rendering/resolve_bound_row.py
index 597cb2e8..79b9e37a 100644
--- a/screens/rendering/resolve_bound_row.py
+++ b/screens/rendering/resolve_bound_row.py
@@ -1,13 +1,17 @@
import db
-from .filter_repeater_rows import filter_repeater_rows, _field_column
+from .filter_repeater_rows import filter_rows_by_clauses, _field_column
+from ..clause_list_codec import decode_clauses
def _resolve_bound_row_ctx(slug, attrs):
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
- DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
- filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
+ DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
+ réglées (un nombre quelconque, combinées par ET/OU — attr:
+ _data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien
+ format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments
+ créés avant l'ajout des clauses illimitées) — même moteur que le
filtre du Répéteur de données, voir filter_repeater_rows.py — et
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
@@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs):
if not definition:
return None
rows = db.list_rows(slug, definition)
- rows = filter_repeater_rows(slug, definition, rows, {
- "_filtre_champ": attrs.get("_data_filtre_champ"),
- "_filtre_operateur": attrs.get("_data_filtre_operateur"),
- "_filtre_valeur": attrs.get("_data_filtre_valeur"),
- "_filtre2_champ": attrs.get("_data_filtre2_champ"),
- "_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
- "_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
- })
+ clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre")
+ rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
if not rows:
return None
row = rows[0]
diff --git a/screens/widgets/control_groups/data_binding_controls.py b/screens/widgets/control_groups/data_binding_controls.py
index 8d8334e1..adbe674f 100644
--- a/screens/widgets/control_groups/data_binding_controls.py
+++ b/screens/widgets/control_groups/data_binding_controls.py
@@ -1,26 +1,20 @@
-from ..controls.c_text import c_text
from ..controls.c_select import c_select
+from ..controls.c_clause_list import c_clause_list
from ...flow.constants import CONDITION_OPERATORS
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
-# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
-# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
-# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
-# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
-# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
+# plutôt qu'une liste. La ligne affichée est celle qui correspond aux
+# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU —
+# mêmes clauses, même moteur que le Répéteur de données, voir
+# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente
+# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet
+# "état de partie" qui ne garde qu'une seule ligne).
DATA_BINDING_CONTROLS = [
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
- c_select("data_filtre_champ", "Trouver la ligne où ce champ",
- "attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
- c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
- c_text("data_filtre_valeur", "...cette valeur",
- "attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
- c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
- "attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
- c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
- c_text("data_filtre2_valeur", "...cette valeur",
- "attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
+ c_clause_list("data_filtres", "Trouver la ligne où...",
+ clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur",
+ operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"),
]
diff --git a/screens/widgets/control_value.py b/screens/widgets/control_value.py
index b20aa867..ab838c87 100644
--- a/screens/widgets/control_value.py
+++ b/screens/widgets/control_value.py
@@ -1,5 +1,7 @@
import re
+from ..clause_list_codec import decode_clauses
+
def _control_value(el, control):
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
@@ -7,8 +9,34 @@ def _control_value(el, control):
ctype = control["type"]
style = el.get("style") or {}
+ if ctype == "clause_list":
+ attrs = el.get("attributes") or {}
+ clauses, combinateur = decode_clauses(
+ attrs, control["clauses_attr"], control["combinator_attr"],
+ legacy_prefix=control.get("legacy_prefix"),
+ )
+ return {"clauses": clauses, "combinateur": combinateur}
+
if ctype == "preset":
- for key, _label, props in control["options"]:
+ # Trié par nombre de propriétés NON VIDES exigées, décroissant : une
+ # option comme "Visible normalement" (visibility_control.py), dont
+ # les critères ({"visibility": ""}) ne portent QUE sur "visibility"
+ # (par choix assumé, pour ne jamais toucher "display" — voir son
+ # commentaire), matchait sinon TOUJOURS en premier dès que
+ # "visibility" est absent, MÊME quand "display:none" (posé par
+ # "Masqué", un critère plus spécifique à 1 propriété non vide contre
+ # 0 pour "Visible") est également présent -- le panneau affichait
+ # alors "Visible normalement" sélectionné sur un élément en réalité
+ # masqué, et comme un