Ajoute icône/largeur/arrondi/gras/majuscules à l'étiquette (badge)
Nouveaux attributs par élément (svg_markup/width/border_radius/bold/ uppercase, tous vides ou False par défaut = comportement historique inchangé), réglables depuis le panneau Propriétés — même esprit que bold/align/color déjà présents sur titre/paragraphe. Fixer une largeur implique toujours de sortir de l'étirement pleine largeur par défaut (align-self:flex-start posé automatiquement avec elle). Le kicker "Module obligatoire" du thème Sécurité Incendie s'en sert maintenant (icône flamme, largeur au contenu, arrondi complet, gras, majuscules) pour être conforme à la maquette d'origine — la puce décorative CSS générique qui la remplaçait disparaît du thème. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1296edc2f8
commit
e03bea39c5
@@ -81,7 +81,20 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
||||
# attribut "ordered" redondant à tenir synchronisé avec le kind.
|
||||
return {"items": ["Premier élément", "Deuxième élément"]}
|
||||
if kind == "badge":
|
||||
return {"content": "Étiquette"}
|
||||
# Même esprit que titre/paragraphe (bold/align/color... déjà des
|
||||
# attributs par élément, pas des choix figés par le moteur) :
|
||||
# svg_markup/width/border_radius/bold/uppercase vides ou False par
|
||||
# défaut = comportement historique inchangé (pleine largeur, sans
|
||||
# icône, casse normale) ; un thème ou le créateur les règle au cas
|
||||
# par cas (retour utilisateur du 24/09/2026).
|
||||
return {
|
||||
"content": "Étiquette",
|
||||
"svg_markup": "",
|
||||
"width": "",
|
||||
"border_radius": "",
|
||||
"bold": False,
|
||||
"uppercase": False,
|
||||
}
|
||||
if kind == "carte":
|
||||
# Contenu pur (lettre/repère court + titre + description) — sans
|
||||
# aucun choix de couleur/forme, laissé au futur système de
|
||||
|
||||
@@ -11,12 +11,18 @@ top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
|
||||
partagent la même structure d'attributs (`items`) ; c'est le `kind`
|
||||
lui-même qui décide `<ul>` ou `<ol>` au rendu (voir
|
||||
`document_engine/rendering/render_document_element.py::_render_list`),
|
||||
jamais un attribut `ordered` redondant. `"badge"` et `"carte"` sont du
|
||||
contenu PUR, sans aucun attribut de style/couleur/forme (décision du
|
||||
24/09/2026 : le moteur ne porte que contenu et mécanisme, tout habillage
|
||||
visuel revient à un futur système de templates) — voir aussi le mode SVG
|
||||
inline de `"image"` (`svg_markup`) et la pièce jointe téléchargeable de
|
||||
`"bouton"` (`attachment_filename`), ci-dessous.
|
||||
jamais un attribut `ordered` redondant. `"carte"` reste du contenu PUR,
|
||||
sans aucun attribut de style/couleur/forme (décision du 24/09/2026 : le
|
||||
moteur ne porte que contenu et mécanisme, tout habillage visuel revient
|
||||
à un futur système de templates). `"badge"` a depuis gagné des
|
||||
attributs de mise en forme PAR ÉLÉMENT (`svg_markup`/`width`/
|
||||
`border_radius`/`bold`/`uppercase`, retour utilisateur du 24/09/2026)
|
||||
— même esprit que `bold`/`align`/`color` déjà présents sur
|
||||
`"titre"`/`"paragraphe"` : des réglages posés par le créateur ou un
|
||||
thème au cas par cas, jamais une valeur figée dans le moteur. Voir
|
||||
aussi le mode SVG inline de `"image"` (`svg_markup`) et la pièce
|
||||
jointe téléchargeable de `"bouton"` (`attachment_filename`),
|
||||
ci-dessous.
|
||||
|
||||
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
||||
@@ -47,8 +53,11 @@ Attributs posés à la création d'un élément de ce type (voir
|
||||
rendu, voir rendering.md), bouton (`label/target/
|
||||
attachment_stored_name/attachment_filename` — la pièce jointe est
|
||||
indépendante de `target`, réservé à la navigation), liste à
|
||||
puces/numérotée (`items`, une liste de chaînes), badge (`content`),
|
||||
carte (`label/title/description`, contenu pur sans couleur/forme),
|
||||
puces/numérotée (`items`, une liste de chaînes), badge
|
||||
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
|
||||
ou `False` par défaut = comportement historique inchangé, voir
|
||||
rendering.md), carte (`label/title/description`, contenu pur sans
|
||||
couleur/forme),
|
||||
rangée (`gap/align/justify`), quiz (`DEFAULT_QUIZ_CONFIG`, voir
|
||||
`quiz_config.py`), association (`DEFAULT_ASSOCIATION_CONFIG`, voir
|
||||
`association_config.py`), memory (`DEFAULT_MEMORY_CONFIG`, voir
|
||||
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from .sanitize_svg_markup import sanitize_svg_markup
|
||||
|
||||
|
||||
def render_document(elements: list[dict[str, Any]]) -> str:
|
||||
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
|
||||
@@ -79,8 +81,6 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
|
||||
# element_kind_labels.element_default_attributes). Nettoyé à
|
||||
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
|
||||
# même défense en profondeur que html.escape sur les autres kinds.
|
||||
from .sanitize_svg_markup import sanitize_svg_markup
|
||||
|
||||
sanitized = sanitize_svg_markup(svg_markup)
|
||||
return f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image">{sanitized}</div>'
|
||||
src = html_lib.escape(str(a.get("src", "")))
|
||||
@@ -128,8 +128,31 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
|
||||
|
||||
|
||||
def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
content = html_lib.escape(str(el["attributes"].get("content", "")))
|
||||
return f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge">{content}</div>'
|
||||
a = el["attributes"]
|
||||
content = html_lib.escape(str(a.get("content", "")))
|
||||
svg_markup = str(a.get("svg_markup", "")).strip()
|
||||
icon_html = f'<span class="docBadgeIcon">{sanitize_svg_markup(svg_markup)}</span>' if svg_markup else ""
|
||||
|
||||
style_parts = []
|
||||
width = str(a.get("width", "")).strip()
|
||||
if width:
|
||||
# Fixer une largeur implique de ne plus s'étirer sur toute la
|
||||
# largeur de .docPageContent (comportement par défaut d'un enfant
|
||||
# flex en colonne, voir static/document/document-editor.css) —
|
||||
# les deux vont toujours ensemble, jamais l'un sans l'autre.
|
||||
style_parts.append(f"align-self:flex-start; width:{html_lib.escape(width)};")
|
||||
border_radius = str(a.get("border_radius", "")).strip()
|
||||
if border_radius:
|
||||
style_parts.append(f"border-radius:{html_lib.escape(border_radius)};")
|
||||
if a.get("bold"):
|
||||
style_parts.append("font-weight:800;")
|
||||
if a.get("uppercase"):
|
||||
style_parts.append("text-transform:uppercase;")
|
||||
style_attr = f' style="{" ".join(style_parts)}"' if style_parts else ""
|
||||
|
||||
return (
|
||||
f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge"{style_attr}>{icon_html}{content}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_carte(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
|
||||
@@ -50,7 +50,18 @@ regroupement à chaque appel.
|
||||
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
|
||||
le reste des appels AJAX de l'éditeur).
|
||||
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
|
||||
échappé — contenu pur, aucun attribut de style.
|
||||
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
|
||||
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
|
||||
mécanisme que le mode SVG de `"image"`). `width` (non vide) ajoute
|
||||
`align-self:flex-start; width:{valeur};` en style inline — fixer une
|
||||
largeur implique TOUJOURS de sortir de l'étirement pleine largeur par
|
||||
défaut d'un enfant flex en colonne (voir static/document/
|
||||
document-editor.css, `.docPageContent`), jamais l'un sans l'autre.
|
||||
`border_radius` (non vide) ajoute `border-radius:{valeur};`. `bold`/
|
||||
`uppercase` ajoutent respectivement `font-weight:800;`/
|
||||
`text-transform:uppercase;` quand `True`. Tous ces attributs sont vides
|
||||
ou `False` par défaut (comportement historique inchangé, aucun style
|
||||
inline ajouté).
|
||||
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
|
||||
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
|
||||
couleur/forme choisie ici (voir `element_kind_labels.md`).
|
||||
|
||||
@@ -14,7 +14,21 @@ SECURITE_INCENDIE_SEED_PAGES: list[dict[str, Any]] = [
|
||||
{
|
||||
"vertical_align": "center",
|
||||
"blocks": [
|
||||
{"kind": "badge", "attributes": {"content": "Module obligatoire"}},
|
||||
{
|
||||
"kind": "badge",
|
||||
"attributes": {
|
||||
"content": "Module obligatoire",
|
||||
"svg_markup": (
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor">'
|
||||
'<path d="M12 2C9 6 6 9 6 13a6 6 0 0 0 12 0c0-2-1-3.5-2-5 '
|
||||
'.3 2-.7 3-1.5 2.3C15.5 9 15 6 12 2z"/></svg>'
|
||||
),
|
||||
"width": "fit-content",
|
||||
"border_radius": "999px",
|
||||
"bold": True,
|
||||
"uppercase": True,
|
||||
},
|
||||
},
|
||||
{"kind": "titre", "attributes": {"content": "Formation Sécurité Incendie", "style": "titre1"}},
|
||||
{
|
||||
"kind": "paragraphe",
|
||||
|
||||
@@ -1800,11 +1800,25 @@ function forgeDocRenderBadgeProps(panel, el) {
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('étiquette')}
|
||||
<div class="docField"><label for="docBadgeContent">Contenu</label><input type="text" id="docBadgeContent" value="${forgeDocEscapeHtml(a.content || '')}"></div>
|
||||
<div class="docField"><label for="docBadgeSvg">Icône SVG (optionnel)</label><textarea id="docBadgeSvg" rows="3">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Mise en forme</span>
|
||||
<div class="docToolbarRow">
|
||||
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBadgeBoldBtn" title="Gras">B</button>
|
||||
<button type="button" class="docFmtBtn ${a.uppercase ? 'is-active' : ''}" id="docBadgeUppercaseBtn" title="Majuscules">AA</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docField"><label for="docBadgeWidth">Largeur (optionnel, ex. fit-content, 220px)</label><input type="text" id="docBadgeWidth" value="${forgeDocEscapeHtml(a.width || '')}" placeholder="pleine largeur"></div>
|
||||
<div class="docField"><label for="docBadgeRadius">Arrondi des bords (optionnel, ex. 7px, 999px)</label><input type="text" id="docBadgeRadius" value="${forgeDocEscapeHtml(a.border_radius || '')}"></div>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
document.getElementById('docBadgeContent').addEventListener('change', (e) => {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, content: e.target.value });
|
||||
});
|
||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||
document.getElementById('docBadgeContent').addEventListener('change', (e) => patch({ content: e.target.value }));
|
||||
document.getElementById('docBadgeSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
|
||||
document.getElementById('docBadgeBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
|
||||
document.getElementById('docBadgeUppercaseBtn').addEventListener('click', () => patch({ uppercase: !a.uppercase }));
|
||||
document.getElementById('docBadgeWidth').addEventListener('change', (e) => patch({ width: e.target.value.trim() }));
|
||||
document.getElementById('docBadgeRadius').addEventListener('change', (e) => patch({ border_radius: e.target.value.trim() }));
|
||||
}
|
||||
|
||||
function forgeDocRenderCarteProps(panel, el) {
|
||||
|
||||
@@ -61,10 +61,15 @@
|
||||
background: linear-gradient(90deg, var(--theme-safety-red), var(--theme-hazard-amber));
|
||||
}
|
||||
|
||||
/* ---- Étiquette (badge) : encart compact type avertissement/repère,
|
||||
avec une puce décorative (::before, glyphe générique — le kind
|
||||
"badge" du moteur ne porte aucun slot d'icône, voir
|
||||
document_engine/rendering/render_document_element.py::_render_badge). ---- */
|
||||
/* ---- Étiquette (badge) : encart compact type avertissement/repère.
|
||||
L'icône est maintenant un vrai contenu (attribut svg_markup, voir
|
||||
_render_badge) plutôt qu'un glyphe CSS décoratif — un thème n'a plus
|
||||
qu'à dimensionner l'icône fournie, jamais à en inventer une. Largeur/
|
||||
arrondi/gras/majuscules restent des attributs PAR ÉLÉMENT (voir
|
||||
element_kind_labels.element_default_attributes) : ce thème ne pose
|
||||
ici que l'apparence par défaut d'un badge SANS ces réglages (pleine
|
||||
largeur, 7px d'arrondi) — le contenu-seed du kicker "Module
|
||||
obligatoire" les override lui-même (voir securite_incendie_seed.py). ---- */
|
||||
.docBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -79,10 +84,16 @@
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.docBadge::before {
|
||||
content: "●";
|
||||
.docBadgeIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--theme-safety-red);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.docBadgeIcon svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ---- */
|
||||
|
||||
@@ -232,7 +232,14 @@ def test_badge_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> N
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
element = doc_engine.get_document_element(slug, element_id)
|
||||
assert element is not None
|
||||
assert element["attributes"] == {"content": "Étiquette"}
|
||||
assert element["attributes"] == {
|
||||
"content": "Étiquette",
|
||||
"svg_markup": "",
|
||||
"width": "",
|
||||
"border_radius": "",
|
||||
"bold": False,
|
||||
"uppercase": False,
|
||||
}
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert html == f'<div class="docBadge" data-element-id="{element_id}" data-kind="badge">Étiquette</div>'
|
||||
@@ -248,6 +255,56 @@ def test_badge_escapes_content(tmp_support_slug_cleanup: Any) -> None:
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_badge_with_svg_markup_renders_a_sanitized_icon(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"content": "Module obligatoire",
|
||||
"svg_markup": '<svg onload="alert(1)"><circle cx="1" cy="1" r="1"/><script>alert(2)</script></svg>',
|
||||
},
|
||||
)
|
||||
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert '<span class="docBadgeIcon">' in html
|
||||
assert "<circle" in html
|
||||
assert "onload" not in html
|
||||
assert "<script>" not in html
|
||||
|
||||
|
||||
def test_badge_without_svg_markup_renders_no_icon_span(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "docBadgeIcon" not in html
|
||||
|
||||
|
||||
def test_badge_with_width_also_sets_align_self(tmp_support_slug_cleanup: Any) -> None:
|
||||
"""Fixer une largeur doit TOUJOURS sortir du comportement par défaut
|
||||
(pleine largeur, étirement flex) — les deux vont toujours ensemble,
|
||||
voir render_document_element._render_badge."""
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(slug, element_id, {"content": "X", "width": "fit-content"})
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "align-self:flex-start;" in html
|
||||
assert "width:fit-content;" in html
|
||||
|
||||
|
||||
def test_badge_border_radius_bold_and_uppercase_render_inline_style(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"content": "X", "border_radius": "999px", "bold": True, "uppercase": True}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "border-radius:999px;" in html
|
||||
assert "font-weight:800;" in html
|
||||
assert "text-transform:uppercase;" in html
|
||||
|
||||
|
||||
def test_carte_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
|
||||
|
||||
@@ -32,6 +32,22 @@ def test_securite_incendie_title_page_is_vertically_centered() -> None:
|
||||
assert entry["seed_pages"][1].get("vertical_align", "top") == "top"
|
||||
|
||||
|
||||
def test_securite_incendie_kicker_badge_uses_the_new_badge_properties() -> None:
|
||||
"""Retour utilisateur du 24/09/2026 : l'étiquette "Module obligatoire"
|
||||
doit avoir une icône, une largeur au contenu, un arrondi complet et
|
||||
du gras/majuscules, comme dans la maquette d'origine."""
|
||||
entry = doc_engine.get_document_theme_entry("securite-incendie")
|
||||
assert entry is not None
|
||||
kicker = entry["seed_pages"][0]["blocks"][0]
|
||||
assert kicker["kind"] == "badge"
|
||||
attrs = kicker["attributes"]
|
||||
assert attrs["svg_markup"]
|
||||
assert attrs["width"] == "fit-content"
|
||||
assert attrs["border_radius"] == "999px"
|
||||
assert attrs["bold"] is True
|
||||
assert attrs["uppercase"] is True
|
||||
|
||||
|
||||
def test_securite_incendie_intro_paragraph_has_a_max_width() -> None:
|
||||
"""Retour utilisateur du 24/09/2026 : le sous-titre de la page de
|
||||
garde doit rester aussi étroit que dans la maquette, pas étiré sur
|
||||
|
||||
Reference in New Issue
Block a user