diff --git a/docs/plan/AUDIT_MISE_EN_FORME.md b/docs/plan/AUDIT_MISE_EN_FORME.md
new file mode 100644
index 00000000..a4092b37
--- /dev/null
+++ b/docs/plan/AUDIT_MISE_EN_FORME.md
@@ -0,0 +1,108 @@
+# Audit des options de mise en forme — état des lieux et suivi
+
+Document de travail (retour utilisateur du 26/09/2026) : liste tout ce
+qui manque en CSS, élément par élément, et sert de suivi pendant
+l'implémentation (un élément à la fois, dans l'ordre du tableau —
+implémentation → test utilisateur → validation → audit final → commit →
+élément suivant).
+
+## Méthode de travail (rappel)
+
+1. Un élément du tableau à la fois, dans l'ordre.
+2. J'implémente tout ce qui manque pour cet élément.
+3. Je préviens l'utilisateur, qui teste et valide/invalide.
+4. Dernier audit sur l'élément pour vérifier que rien n'est oublié.
+5. Si tout est ok : commit, puis élément suivant.
+
+Règle transversale (retour utilisateur) : **jamais de champ de texte
+libre** pour une valeur CSS — uniquement des boutons, des curseurs, des
+listes déroulantes, des sélecteurs de couleur natifs. Les valeurs
+numériques optionnelles utilisent un curseur + une case "activer"
+(un `` ne peut pas représenter "aucune valeur").
+
+## Tableau d'audit (état au 26/09/2026, avant implémentation)
+
+| Élément | Mise en forme possible aujourd'hui | Ce qui manque (audit CSS complet) |
+|---|---|---|
+| **Titre / Paragraphe** (`titre`/`paragraphe`) | Gras, italique, souligné, couleur du texte, alignement horizontal du texte, largeur maximale | **Typo** : barré, surligné (overline), police de caractère, taille de police (actuellement figée par le style titre1/titre2/paragraphe/légende), espacement des lettres/mots, hauteur de ligne, transformation (majuscules/minuscules/capitales), indentation de la 1ʳᵉ ligne, ombre portée du texte, retour à la ligne (`white-space`), troncature avec "…" (`text-overflow`), sens d'écriture (RTL) **Boîte** : padding, margin (y compris négatif/auto pour centrer), hauteur, min/max-height, min-width **Bordure/ombre** : style (pointillé/tireté/double…), épaisseur, couleur, par côté, arrondi par coin, ombre portée du bloc (`box-shadow`) **Fond** : couleur de fond, dégradé, image de fond, opacité du bloc **Position/affichage** : position du bloc dans son conteneur (pas juste le texte dedans), `overflow`, `z-index` **Effets** : transition/animation au survol, curseur, filtre (flou, contraste…) **Responsive** : aucune valeur ne peut différer entre Bureau/Tablette/Mobile |
+| **Image** (`image`) | URL, texte alternatif, SVG inline | **Dimensionnement** : largeur/hauteur explicites, min/max, ratio (`aspect-ratio`), `object-fit` (cover/contain/fill), `object-position` **Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin (actuellement fixe à 10px, non réglable), ombre portée **Effets** : filtre CSS (niveaux de gris, sépia, luminosité, flou), `mix-blend-mode`, découpe (`clip-path`, ex. cercle/hexagone), overlay couleur/dégradé au survol **Comportement** : lien cliquable, ouverture en plein écran/lightbox au clic, chargement différé (`loading=lazy`), légende (caption) affichée sous l'image **Position** : alignement horizontal dans son conteneur, `margin`, `padding` autour **Responsive** : image différente ou recadrage différent par taille d'écran |
+| **Bouton** (`bouton`) | Texte, cible (URL/ancre), fichier joint | **Typo** : police, taille, gras/italique, transformation (majuscules), espacement des lettres **Boîte** : padding, margin, largeur/hauteur, alignement du bloc **Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin, ombre portée **Fond** : couleur, dégradé, image **États interactifs** : styles distincts survol/actif/désactivé (aucune notion d'état n'existe) **Icône** : aucune icône à côté du texte du bouton (contrairement au badge) **Effets** : transition au survol, curseur |
+| **Liste à puces / numérotée** (`liste_puces`/`liste_numerotee`) | Contenu des éléments uniquement | **Typo** : tout (police, taille, gras/italique/souligné, couleur, interligne) **Puces/numéros** : style de puce (`list-style-type`), image de puce personnalisée, position (intérieure/extérieure), couleur/taille des puces indépendante du texte **Boîte** : padding/margin globaux ET par élément de liste, espacement entre éléments réglable, indentation **Bordure/fond** : par élément de liste ou sur la liste entière **Listes imbriquées** : aucune notion de sous-liste |
+| **Étiquette** (`badge`) | Icône SVG, largeur, arrondi (uniforme), gras, majuscules | **Typo** : italique, souligné, barré, police, taille de police, couleur du texte, espacement des lettres **Boîte** : hauteur, padding, margin, min/max-width **Bordure** : style (actuellement toujours plein), couleur, épaisseur, **par côté** (demandé explicitement), arrondi **par coin** (actuellement un seul rayon pour les 4 coins) **Fond** : couleur (actuellement figée par le thème), dégradé **Ombre** : `box-shadow` **Position de l'icône** : avant/après le texte, taille de l'icône réglable |
+| **Carte** (`carte`) | Contenu uniquement (repère/titre/description) | Tout : typo, boîte, bordure, fond, ombre, dimensionnement — rien n'est réglable |
+| **Rangée** (`row`, conteneur flex) | `gap`, `justify-content`, `align-items` | **Boîte** : padding, margin, largeur/hauteur explicites avec poignées de redimensionnement **Bordure/fond/ombre** : rien **Flex avancé** : `flex-wrap` (retour à la ligne), `flex-direction` (actuellement toujours en ligne, jamais en colonne), `align-content`, ordre des enfants, `flex-grow`/`flex-basis` par enfant (chacun a la même part aujourd'hui) **Position** : `overflow`, `position` (sticky/absolute), `z-index` **Responsive** : passage automatique en colonne sous un seuil de largeur |
+| **Mini-jeux** (`quiz`/`association`/`memory`/`mots`/`scenario`/`zones`) | `theme_color` (accent) + contenu propre à chaque jeu | Tout le reste : bordure, fond, dimensions, police, espacement, ombre — entièrement figés par le CSS du thème, aucun réglage par instance |
+
+**Catégories transversales oubliées, valables pour tous les éléments** :
+`opacity` (transparence du bloc), `cursor`, transitions/animations CSS,
+styles conditionnels par état (survol/focus/actif/désactivé), et le
+**responsive** (aucun réglage ne peut varier entre Bureau/Tablette/Mobile
+alors que ce sélecteur existe déjà dans l'éditeur).
+
+## Suivi par élément
+
+### 1. Titre / Paragraphe — ✅ audité et validé (commité)
+
+Implémenté : gras/italique/souligné/**barré**, majuscules/minuscules/
+capitales, police (liste déroulante de polices web-safe), taille de
+police, hauteur de ligne, espacement des lettres, ombre du texte (3
+préréglages), couleur du texte (sélecteur natif), largeur maximale,
+padding, margin, couleur de fond (+ bouton "Transparent"), arrondi des
+bords, hauteur/hauteur min/hauteur max/largeur min, ombre portée du
+bloc (3 préréglages), opacité, position du bloc (gauche/centré/droite/
+pleine largeur), bordure par côté (style/épaisseur/couleur,
+indépendants sur les 4 côtés).
+
+Mécanisme partagé créé pour l'occasion : `document_engine/rendering/
+box_style.py` (`render_box_style`, `default_border`, `BOX_DEFAULTS`) —
+réutilisé tel quel par tous les éléments suivants du tableau.
+
+Volontairement laissé de côté (rarement utile pour du contenu de
+formation, à ajouter si besoin) : surlignage (overline), indentation de
+1ʳᵉ ligne, `white-space`/troncature "…", sens d'écriture RTL, dégradé/
+image de fond, `overflow`/`z-index`/position absolue, transitions au
+survol, réglages différents par taille d'écran (responsive), arrondi
+par coin (un seul rayon pour les 4 coins, comme l'étiquette).
+
+Bugs transversaux trouvés et corrigés pendant ce chantier (concernent
+TOUT l'éditeur, pas seulement Titre/Paragraphe) :
+- Panneau Propriétés jamais reconstruit après un clic (boutons/segments
+ ne reflétaient leur nouvel état qu'après rechargement de la page) —
+ corrigé dans `forgeDocUpdateAttributes`, point d'entrée unique de
+ toute mise à jour d'attribut.
+- Champs `.docField input` (padding/bordure/fond/largeur 100%) hérités
+ à tort par les cases à cocher et curseurs, cassant leur apparence
+ native.
+
+### 2. Image — à faire (prochain élément)
+
+### 3. Bouton — à faire
+
+### 4. Liste à puces / numérotée — à faire
+
+### 5. Étiquette (badge) — à compléter
+
+Déjà fait (session précédente) : icône SVG, largeur, arrondi uniforme,
+gras, majuscules. Restant selon le tableau ci-dessus : italique,
+souligné, barré, police, taille de police, couleur du texte, espacement
+des lettres, hauteur, padding, margin, bordure par côté, ombre portée,
+position de l'icône. Pourra réutiliser directement `box_style.py`.
+
+### 6. Carte — à faire
+
+### 7. Rangée (row) — à faire
+
+### 8. Mini-jeux — à faire
+
+## Décisions transversales actées pendant ce chantier
+
+- Un seul rayon d'arrondi pour les 4 coins (pas par coin) — cohérent
+ avec l'étiquette déjà en place, à revoir si demandé explicitement.
+- Padding/margin : une valeur UNIFORME sur les 4 côtés (curseur unique),
+ pas par côté — la bordure, elle, reste par côté (demande explicite).
+- Ombres (texte et bloc) : 3 préréglages (Aucune/Légère/Marquée) plutôt
+ que des curseurs séparés offset/flou/couleur — reste simple à
+ utiliser, ajustable si besoin de plus de finesse plus tard.
+- Polices : liste fermée de polices web-safe (aucun chargement dynamique
+ de Google Fonts depuis l'éditeur) — évite d'introduire un mécanisme de
+ chargement de police, hors périmètre de cet audit.
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index 1bd47420..d9ade5ae 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -5,6 +5,7 @@ défaut posés à la création de chaque type."""
from typing import Any
+from ..rendering.box_style import BOX_DEFAULTS, default_border
from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG
@@ -44,12 +45,26 @@ _TEXT_DEFAULTS = {
"bold": False,
"italic": False,
"underline": False,
+ "strikethrough": False,
"align": "left",
"color": "var(--forge-text)",
# Vide par défaut = pleine largeur de .docPageContent (comportement
# historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint
# la largeur du bloc — voir render_document_element._render_text.
"max_width": "",
+ # Vides par défaut = valeurs du préréglage `style` inchangées (voir
+ # _STYLE_PRESETS dans render_document_element.py) — une valeur
+ # explicite les remplace (retour utilisateur du 26/09/2026, audit des
+ # réglages manquants).
+ "font_family": "",
+ "font_size": "",
+ "line_height": "",
+ "letter_spacing": "",
+ "text_transform": "none",
+ "text_shadow": "",
+ # Attributs de boîte partagés avec d'autres kinds (padding/margin/
+ # background_color/border_radius/align_self) — voir rendering/box_style.py.
+ **BOX_DEFAULTS,
}
@@ -58,9 +73,17 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ."""
if kind == "titre":
- return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
+ # "border" copié à chaque appel (jamais un dict partagé/muté par
+ # référence entre plusieurs éléments — même raison que la copie
+ # de "questions" pour le quiz plus bas).
+ return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()}
if kind == "paragraphe":
- return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
+ return {
+ "content": "Nouveau paragraphe de texte.",
+ "style": "paragraphe",
+ **_TEXT_DEFAULTS,
+ "border": default_border(),
+ }
if kind == "image":
# svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index fb0c5520..ef6cc180 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -45,10 +45,15 @@ l'affichage/debug hors bibliothèque).
Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
- texte (`content/style` + `bold/italic/underline/align/color/max_width` —
- `max_width` vide par défaut = pleine largeur de `.docPageContent`,
- une valeur CSS libre ex. `"60ch"`/`"480px"` restreint le bloc, voir
- `rendering.md`),
+ texte (`content/style` + `bold/italic/underline/strikethrough/align/
+ color/max_width/font_family/font_size/line_height/letter_spacing/
+ text_transform/text_shadow` + les attributs de boîte partagés
+ `padding/margin/background_color/border_radius/height/min_height/
+ max_height/min_width/box_shadow/opacity/align_self/border`, voir
+ `rendering/box_style.py` — tous vides, `False` ou `"none"`/`"stretch"`
+ par défaut = comportement historique inchangé ; `max_width`
+ restreint la largeur du bloc, une valeur CSS libre ex. `"60ch"`/
+ `"480px"`, voir `rendering.md`),
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est
diff --git a/document_engine/rendering/box_style.py b/document_engine/rendering/box_style.py
new file mode 100644
index 00000000..8b11bb70
--- /dev/null
+++ b/document_engine/rendering/box_style.py
@@ -0,0 +1,109 @@
+"""Attributs de mise en forme de "boîte" PARTAGÉS par plusieurs kinds de
+contenu (padding/margin/background_color/border_radius/border/align_self)
+— un seul et même jeu d'attributs et une seule fonction de rendu pour ne
+jamais dupliquer cette logique entre `_render_text`/`_render_image`/
+`_render_button`/etc. (voir retour utilisateur du 26/09/2026 : audit
+complet des réglages manquants, à ajouter élément par élément en
+réutilisant CE module à chaque fois plutôt que de le réécrire)."""
+
+import html as html_lib
+from typing import Any
+
+BORDER_SIDES = ("top", "right", "bottom", "left")
+
+_DEFAULT_BORDER_SIDE = {"style": "none", "width": "1px", "color": "var(--doc-border)"}
+
+
+def default_border() -> dict[str, dict[str, str]]:
+ """Nouveau dict à chaque appel (jamais un littéral partagé/muté par
+ référence entre plusieurs éléments, même précaution que
+ DEFAULT_QUIZ_CONFIG côté labels)."""
+ return {side: dict(_DEFAULT_BORDER_SIDE) for side in BORDER_SIDES}
+
+
+BOX_DEFAULTS = {
+ "padding": "",
+ "margin": "",
+ "background_color": "",
+ "border_radius": "",
+ "align_self": "stretch",
+ "height": "",
+ "min_height": "",
+ "max_height": "",
+ "min_width": "",
+ "box_shadow": "",
+ "opacity": "",
+}
+
+
+def render_box_style(a: dict[str, Any]) -> str:
+ """Construit les déclarations CSS inline communes à plusieurs kinds à
+ partir des attributs `padding`/`margin`/`background_color`/
+ `border_radius`/`height`/`min_height`/`max_height`/`min_width`/
+ `box_shadow`/`opacity`/`border`/`align_self` de `a` — chaîne vide pour
+ tout attribut absent ou à sa valeur par défaut (aucun style ajouté,
+ comportement historique inchangé). `border` est un dict à 4 clés
+ (`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
+ `style="none"` (ou absent) ne produit aucune déclaration pour ce
+ côté, jamais un `border-top:none` explicite.
+ - **Retour** : les déclarations CSS (`"propriete:valeur; ..."`),
+ jamais vide ni `None`.
+ - **Exceptions** : aucune."""
+ parts: list[str] = []
+
+ padding = str(a.get("padding", "")).strip()
+ if padding:
+ parts.append(f"padding:{html_lib.escape(padding)};")
+
+ margin = str(a.get("margin", "")).strip()
+ if margin:
+ parts.append(f"margin:{html_lib.escape(margin)};")
+
+ background_color = str(a.get("background_color", "")).strip()
+ if background_color:
+ parts.append(f"background-color:{html_lib.escape(background_color)};")
+
+ border_radius = str(a.get("border_radius", "")).strip()
+ if border_radius:
+ parts.append(f"border-radius:{html_lib.escape(border_radius)};")
+
+ height = str(a.get("height", "")).strip()
+ if height:
+ parts.append(f"height:{html_lib.escape(height)};")
+ min_height = str(a.get("min_height", "")).strip()
+ if min_height:
+ parts.append(f"min-height:{html_lib.escape(min_height)};")
+ max_height = str(a.get("max_height", "")).strip()
+ if max_height:
+ parts.append(f"max-height:{html_lib.escape(max_height)};")
+ min_width = str(a.get("min_width", "")).strip()
+ if min_width:
+ parts.append(f"min-width:{html_lib.escape(min_width)};")
+
+ box_shadow = str(a.get("box_shadow", "")).strip()
+ if box_shadow:
+ parts.append(f"box-shadow:{html_lib.escape(box_shadow)};")
+
+ opacity = str(a.get("opacity", "")).strip()
+ if opacity:
+ parts.append(f"opacity:{html_lib.escape(opacity)};")
+
+ border = a.get("border") or {}
+ for side in BORDER_SIDES:
+ side_border = border.get(side) or {}
+ style = str(side_border.get("style", "none"))
+ if style and style != "none":
+ width = html_lib.escape(str(side_border.get("width", "1px")))
+ color = html_lib.escape(str(side_border.get("color", "var(--doc-border)")))
+ parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};")
+
+ # align-self ne fait quoi que ce soit d'utile QUE si l'élément a par
+ # ailleurs une taille bornée (max_width/width) — voir la note dans
+ # element_kind_labels.md — mais reste toujours sûr à poser seul
+ # ("stretch" est déjà le comportement par défaut d'un enfant flex en
+ # colonne, donc jamais ajouté explicitement pour ne rien changer).
+ align_self = str(a.get("align_self", "stretch"))
+ if align_self and align_self != "stretch":
+ parts.append(f"align-self:{html_lib.escape(align_self)};")
+
+ return " ".join(parts)
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index 801695fc..66c4af60 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -3,6 +3,7 @@ import json
import random
from typing import Any
+from .box_style import render_box_style
from .sanitize_svg_markup import sanitize_svg_markup
@@ -51,16 +52,48 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
a = el["attributes"]
content = html_lib.escape(str(a.get("content", "")))
style_name = a.get("style", "paragraphe")
- font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
+ preset_font_size, base_weight, preset_line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
weight = 800 if a.get("bold") else base_weight
font_style = "italic" if a.get("italic") else "normal"
- text_decoration = "underline" if a.get("underline") else "none"
+
+ # underline/strikethrough se combinent (text-decoration-line accepte
+ # plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré"
+ # manquait à côté du souligné déjà existant.
+ decoration_parts = []
+ if a.get("underline"):
+ decoration_parts.append("underline")
+ if a.get("strikethrough"):
+ decoration_parts.append("line-through")
+ text_decoration = " ".join(decoration_parts) if decoration_parts else "none"
+
align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
+
+ # font_size/line_height : vides par défaut = valeurs du préréglage
+ # `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur
+ # explicite les remplace SANS changer `weight` (qui reste piloté par
+ # le préréglage + `bold`).
+ font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size
+ line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height)
+
style = (
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
)
+
+ text_transform = str(a.get("text_transform", "none"))
+ if text_transform and text_transform != "none":
+ style += f" text-transform:{html_lib.escape(text_transform)};"
+ font_family = str(a.get("font_family", "")).strip()
+ if font_family:
+ style += f" font-family:{html_lib.escape(font_family)};"
+ letter_spacing = str(a.get("letter_spacing", "")).strip()
+ if letter_spacing:
+ style += f" letter-spacing:{html_lib.escape(letter_spacing)};"
+ text_shadow = str(a.get("text_shadow", "")).strip()
+ if text_shadow:
+ style += f" text-shadow:{html_lib.escape(text_shadow)};"
+
# max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine
# largeur de .docPageContent, comportement inchangé). Retour
# utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus
@@ -69,6 +102,11 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
max_width = str(a.get("max_width", "")).strip()
if max_width:
style += f" max-width:{html_lib.escape(max_width)};"
+
+ box_style = render_box_style(a)
+ if box_style:
+ style += f" {box_style}"
+
return f'
{content}
'
diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md
index f2e7094a..b722629c 100644
--- a/document_engine/rendering/rendering.md
+++ b/document_engine/rendering/rendering.md
@@ -29,12 +29,24 @@ regroupement à chaque appel.
`justify-content` réels depuis `attributes`), enfants rendus
récursivement.
- **Texte** (`titre`/`paragraphe`) : `
` stylé selon `style` (préréglage
- taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- `max_width` (optionnel, ex. `"60ch"`, `"480px"`) ajoute `max-width` au
- style inline quand non vide — pleine largeur de `.docPageContent` par
- défaut, retour utilisateur du 24/09/2026 (un paragraphe doit pouvoir
- rester plus étroit que la page, sans dépendre d'une rangée qui en
- partagerait la largeur avec un frère).
+ taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
+ `align`/`color`. `underline`/`strikethrough` se combinent dans un seul
+ `text-decoration` (`"underline line-through"` si les deux sont actifs).
+ `font_size`/`line_height` (vides par défaut) remplacent les valeurs du
+ préréglage `style` SANS toucher `font-weight` (toujours piloté par le
+ préréglage + `bold`). `text_transform` (`"none"` par défaut) ajoute
+ `text-transform` quand différent de `"none"`. `font_family`/
+ `letter_spacing`/`text_shadow` (vides par défaut) ajoutent leur
+ déclaration CSS respective quand non vides. `max_width` (optionnel, ex.
+ `"60ch"`, `"480px"`) ajoute `max-width` au style inline quand non vide —
+ pleine largeur de `.docPageContent` par défaut, retour utilisateur du
+ 24/09/2026 (un paragraphe doit pouvoir rester plus étroit que la page,
+ sans dépendre d'une rangée qui en partagerait la largeur avec un frère).
+ Termine par `render_box_style(a)` (voir `box_style.py` ci-dessous) pour
+ `padding`/`margin`/`background_color`/`border_radius`/`border`/
+ `align_self` — attributs PARTAGÉS avec d'autres kinds, jamais dupliqués
+ ici (audit du 26/09/2026, réglages manquants à couvrir élément par
+ élément en réutilisant ce module).
- **Image** : ``, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`
` portant directement ce fragment SVG nettoyé par
@@ -180,6 +192,47 @@ regroupement à chaque appel.
emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe.
+## `box_style.py` — attributs de "boîte" partagés entre plusieurs kinds
+
+Audit du 26/09/2026 (réglages manquants, à couvrir élément par élément) :
+padding/margin/couleur de fond/arrondi/bordure par côté/position du bloc
+sont des besoins IDENTIQUES pour la plupart des kinds de contenu — un
+seul jeu d'attributs et une seule fonction de rendu ici, jamais réécrits
+à chaque kind (voir `_render_text` pour le premier usage réel).
+
+### `BORDER_SIDES: tuple[str, ...]`
+`("top", "right", "bottom", "left")`.
+
+### `default_border() -> dict[str, dict[str, str]]`
+Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
+"1px", "color": "var(--doc-border)"}`.
+- **Retour** : un NOUVEAU dict à chaque appel (jamais un littéral partagé
+ muté par référence entre deux éléments — même précaution que
+ `DEFAULT_QUIZ_CONFIG` côté `labels/`).
+- **Exceptions** : aucune.
+
+### `BOX_DEFAULTS: dict[str, Any]`
+`{"padding": "", "margin": "", "background_color": "", "border_radius":
+"", "align_self": "stretch", "height": "", "min_height": "",
+"max_height": "", "min_width": "", "box_shadow": "", "opacity": ""}` —
+`border` n'y figure PAS (voir `default_border()`, à ajouter séparément
+par chaque appelant pour éviter le partage par référence).
+
+### `render_box_style(a: dict[str, Any]) -> str`
+Construit les déclarations CSS inline pour `padding`/`margin`/
+`background_color`/`border_radius`/`height`/`min_height`/`max_height`/
+`min_width`/`box_shadow`/`opacity`/`border`/`align_self` de `a` — un
+attribut absent ou à sa valeur par défaut ne produit AUCUNE déclaration
+(comportement historique inchangé). `border` est un dict à 4 clés
+(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
+`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
+`border-top:none` explicite. `align-self` n'est ajouté que si différent
+de `"stretch"` (déjà le comportement par défaut d'un enfant flex en
+colonne).
+- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), chaîne
+ vide si rien à ajouter.
+- **Exceptions** : aucune.
+
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
diff --git a/static/document/document-editor.css b/static/document/document-editor.css
index 0c1f20ea..171b2af1 100644
--- a/static/document/document-editor.css
+++ b/static/document/document-editor.css
@@ -2436,3 +2436,130 @@ img.docImage {
border-bottom: 1px solid var(--doc-border);
}
}
+
+/* ---- Éditeur de bordure par côté (panneau Propriétés — voir
+ static/document/js/document-editor.js, forgeDocRenderBoxFieldsHtml) :
+ une ligne par côté (style/épaisseur/couleur), réutilisée par tous les
+ kinds au fil de l'audit du 26/09/2026. ---- */
+.docBorderEditor {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.docBorderSideRow {
+ display: grid;
+ grid-template-columns: 48px 1fr 56px 1fr;
+ gap: 6px;
+ align-items: center;
+}
+
+.docBorderSideLabel {
+ font-size: 12px;
+ color: var(--doc-muted);
+}
+
+.docBorderStyleSelect {
+ width: 100%;
+ min-width: 0;
+ font-size: 12px;
+ padding: 5px 6px;
+ border: 1px solid var(--doc-border);
+ border-radius: 6px;
+ background: var(--doc-bg-2);
+ color: var(--doc-text);
+}
+
+.docBorderWidthRange {
+ width: 100%;
+ min-width: 0;
+}
+
+.docBorderColorInput {
+ width: 100%;
+ min-width: 0;
+ height: 28px;
+ padding: 2px;
+ border: 1px solid var(--doc-border);
+ border-radius: 6px;
+ background: var(--doc-bg-2);
+ cursor: pointer;
+}
+
+/* ---- Curseurs génériques à case "activer" (voir static/document/js/
+ document-editor.js, forgeDocRenderToggleSliderHtml) — retour
+ utilisateur du 26/09/2026 : plus aucune valeur CSS saisie à la main,
+ uniquement des curseurs/cases/listes/sélecteurs de couleur. ---- */
+.docSliderField {
+ margin-bottom: 10px;
+}
+
+.docSliderRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* `.docField input` (règle générique plus haut) pose padding/bordure/
+ fond/width:100% pensés pour un champ texte — remis à plat ici pour
+ une case à cocher/un curseur natifs, sinon ils héritent cette
+ apparence de champ texte (bug réel constaté le 26/09/2026, capture à
+ l'appui : case à cocher démesurée et centrée, curseur poussé hors
+ panneau). */
+.docSliderRow input[type='checkbox'] {
+ flex-shrink: 0;
+ width: auto;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+}
+
+.docSliderRow input[type='range'] {
+ flex: 1;
+ min-width: 0;
+ width: auto;
+ padding: 0;
+ border: none;
+ background: none;
+}
+
+.docSliderRow input[type='range']:disabled {
+ opacity: 0.4;
+}
+
+.docSliderValue {
+ flex-shrink: 0;
+ min-width: 3em;
+ text-align: right;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--doc-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.docColorFieldRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.docColorFieldRow input[type='color'] {
+ width: 40px;
+ height: 28px;
+ padding: 2px;
+ border: 1px solid var(--doc-border);
+ border-radius: 6px;
+ background: var(--doc-bg-2);
+ cursor: pointer;
+}
+
+#docPropsPanel input[type='color'] {
+ width: 40px;
+ height: 28px;
+ padding: 2px;
+ border: 1px solid var(--doc-border);
+ border-radius: 6px;
+ background: var(--doc-bg-2);
+ cursor: pointer;
+}
diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js
index 5b9dab19..8325feb5 100644
--- a/static/document/js/document-editor.js
+++ b/static/document/js/document-editor.js
@@ -282,6 +282,17 @@ function forgeDocUpdateAttributes(id, newAttributes) {
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
+ // Bug réel constaté le 26/09/2026 : les boutons (gras/alignement/
+ // segmentés...) ne reflétaient jamais leur nouvel état tant que la
+ // page n'était pas rechargée — leur classe "is-active" était figée
+ // au moment du rendu initial du panneau, jamais réévaluée après un
+ // clic. Point d'entrée UNIQUE de toute mise à jour d'attributs :
+ // reconstruire le panneau ICI, une fois, couvre TOUS les panneaux
+ // (texte, image, bouton, mini-jeux...) sans dupliquer cet appel dans
+ // chacun de leurs boutons.
+ if (window.forgeDocState.selectedId === id) {
+ forgeDocRenderProps(el);
+ }
});
}
@@ -1687,6 +1698,208 @@ function forgeDocRefreshRightPanelDefault() {
else forgeDocRenderProps(null);
}
+/* ---------------------------------------------------------------------
+ * Champs de "boîte" PARTAGÉS par plusieurs kinds (padding/margin/couleur
+ * de fond/arrondi/bordure par côté/position du bloc) — voir
+ * document_engine/rendering/box_style.py, même jeu d'attributs côté
+ * serveur. Un seul HTML + un seul binding réutilisés par chaque panneau
+ * de propriétés plutôt que dupliqués (retour utilisateur du 26/09/2026 :
+ * audit complet des réglages manquants, à couvrir élément par élément).
+ * ------------------------------------------------------------------- */
+
+const FORGE_DOC_BORDER_SIDES = [
+ ['top', 'Haut'], ['right', 'Droite'], ['bottom', 'Bas'], ['left', 'Gauche'],
+];
+const FORGE_DOC_BORDER_STYLES = [
+ ['none', 'Aucune'], ['solid', 'Solide'], ['dashed', 'Tirets'], ['dotted', 'Pointillés'], ['double', 'Double'],
+];
+const FORGE_DOC_ALIGN_SELF_OPTIONS = [
+ ['stretch', 'Pleine largeur'], ['flex-start', 'Gauche'], ['center', 'Centré'], ['flex-end', 'Droite'],
+];
+const FORGE_DOC_SHADOW_PRESETS = [
+ ['', 'Aucune'], ['0 2px 6px rgba(0,0,0,.15)', 'Légère'], ['0 8px 24px rgba(0,0,0,.25)', 'Marquée'],
+];
+const FORGE_DOC_TEXT_SHADOW_PRESETS = [
+ ['', 'Aucune'], ['1px 1px 2px rgba(0,0,0,.35)', 'Légère'], ['2px 2px 6px rgba(0,0,0,.5)', 'Marquée'],
+];
+const FORGE_DOC_FONT_FAMILY_OPTIONS = [
+ ['', 'Police du thème'],
+ ['system-ui, -apple-system, sans-serif', 'Système (sans-serif)'],
+ ['Georgia, serif', 'Georgia (serif)'],
+ ['"Times New Roman", serif', 'Times New Roman (serif)'],
+ ['Arial, Helvetica, sans-serif', 'Arial'],
+ ['Verdana, sans-serif', 'Verdana'],
+ ['Tahoma, sans-serif', 'Tahoma'],
+ ['"Trebuchet MS", sans-serif', 'Trebuchet MS'],
+ ['"Courier New", monospace', 'Courier New (machine à écrire)'],
+];
+
+/* ---------------------------------------------------------------------
+ * Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 :
+ * "jamais l'utilisateur ne doit rentrer une valeur brute à la main" —
+ * un curseur (avec une case "activer" puisqu'un ne
+ * peut pas représenter "aucune valeur") remplace tout champ de texte
+ * libre pour une valeur numérique optionnelle.
+ * ------------------------------------------------------------------- */
+
+function forgeDocRenderToggleSliderHtml(idPrefix, label, rawValue, opts) {
+ const hasValue = rawValue !== undefined && rawValue !== null && rawValue !== '';
+ const numeric = hasValue ? Number.parseFloat(rawValue) : opts.fallback;
+ const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
+ return `
+