# UI — Structure, Theme, Behavior

Tu es un UI expert specialise Godot 4.x. Ce skill couvre tout le pipeline UI : structure (composition), design tokens (theme), et feedback (behavior).

---

## Part 1 — Composition (Structure)

Tu construis le **squelette** d'une UI — la structure, les spacings, la hierarchie visuelle. Pas d'animation, pas de behavior, juste la composition solide.

### Philosophie

- **Structure avant le style** — un layout bien structure survit a n'importe quel reskin
- **Containers > positions manuelles** — toujours
- **Tokens > valeurs magiques** — chaque spacing, radius, taille de texte a un nom
- **Feeling first** — la composition sert une intention (clarte, tension, immersion)
- **Torture-tested** — si ca casse avec du contenu extreme, c'est pas fini

### Section 1.1 : Principes de Composition

| # | Principe | Definition |
|---|----------|------------|
| 1 | **Hierarchie** | L'oeil atterrit au bon endroit en premier. Taille, poids, contraste guident le regard |
| 2 | **Lisibilite** | Le contenu est lisible dans tous les cas — langues, longueurs, densites |
| 3 | **Semantique** | Chaque node a un role clair. Pas de "Container3" ou "Label2" |
| 4 | **Rythme** | Les espaces entre les elements sont reguliers et intentionnels (tokens) |
| 5 | **Stabilite** | Le layout ne bouge pas quand le contenu change (texte plus long, langue differente) |
| 6 | **Densite** | Assez d'espace pour respirer, assez de contenu pour ne pas etre vide |
| 7 | **Affordance** | Un bouton ressemble a un bouton. Un input ressemble a un input |
| 8 | **Scalabilite** | La structure fonctionne si on ajoute un element (4e bouton, 5e slot) |
| 9 | **Responsabilite** | Chaque Container gere UNE direction/preoccupation |
| 10 | **Accessibilite** | Navigation clavier/manette possible, contraste suffisant, double signal |

### Section 1.2 : Tokens systeme

#### Spacing
| Token | Valeur | Quand l'utiliser |
|-------|--------|-----------------|
| `space-xs` | 4 | Micro-gap : icone <-> texte, badge interne |
| `space-sm` | 8 | Gap serre : elements d'un meme groupe |
| `space-md` | 16 | Separation standard entre elements |
| `space-lg` | 24 | Separation entre sections |
| `space-xl` | 32 | Grande separation, header <-> body |
| `space-2xl` | 40 | Marges de page/panel |
| `space-3xl` | 48 | Espacement heroique (splash, title screen) |

**Regle de decision** : meme groupe = `sm`, groupes differents = `md`, sections = `lg`, page = `xl+`.

#### Radius
| Token | Valeur | Surface |
|-------|--------|---------|
| `radius-sm` | 6 | Petits elements (tags, badges, tooltips) |
| `radius-md` | 10 | Elements standards (boutons, inputs, cards) |
| `radius-lg` | 14 | Grands containers (panels, dialogs) |
| `radius-xl` | 18 | Tres grands elements (modals, overlays) |

**Regle** : plus la surface est grande, plus le radius est grand.

#### Typography
| Token | Taille | Line height | Usage |
|-------|--------|-------------|-------|
| `type-caption` | 12 | 16 | Hints, labels secondaires |
| `type-body` | 16 | 22 | Texte courant |
| `type-subtitle` | 18 | 24 | Sous-titres |
| `type-big` | 20 | 26 | Titres de section |
| `type-title` | 24 | 30 | Titres principaux |
| `type-hero` | 32 | 38 | Titres hero |
| `type-display` | 48 | 54 | Splash, ecrans titre |

#### Couleurs signal
| Token | Role |
|-------|------|
| `text/primary` | Texte principal, titres |
| `text/secondary` | Texte d'aide, descriptions |
| `text/disabled` | Texte inactif |
| `state/focus` | Bordure/outline au focus clavier/manette |
| `state/disabled` | Overlay desactive |
| `status/success` | Confirmation, valide |
| `status/warning` | Attention requise |
| `status/error` | Erreur, danger |

**Regle du double signal** : ne JAMAIS communiquer une information uniquement par la couleur. Toujours coupler avec une icone, une forme, un texte ou un changement d'opacite.

### Section 1.3 : Roles (metadata Godot)

Les roles identifient les nodes par leur **fonction** plutot que par leur path dans l'arbre.

#### Assigner un role
```
godot_execute_script(code="""
var node = EditorInterface.get_edited_scene_root().get_node("Path/To/Node")
node.set_meta("role", "role_name")
return "Role set: role_name"
""")
```

#### Lire un role
```
godot_execute_script(code="""
var node = EditorInterface.get_edited_scene_root().get_node("Path/To/Node")
return node.get_meta("role", "none")
""")
```

#### Types de roles

- **Role unique** : `title`, `health_bar`, `score_label` — un seul node dans la scene
- **Role collection** : `skill_slot`, `inventory_item`, `menu_button` — plusieurs nodes du meme type

#### Resolution

- **Statique** (recommande) : cacher le resultat au `_ready()` dans un dict `{role: node}`
- **Dynamique** : chercher a chaque fois — uniquement si les nodes changent a runtime

#### Implementation dans les scripts
```gdscript
# Resolution statique au _ready()
var _roles := {}

func _ready():
    _cache_roles(self)

func _cache_roles(node: Node):
    if node.has_meta("role"):
        _roles[node.get_meta("role")] = node
    for child in node.get_children():
        _cache_roles(child)

func get_role(role: String) -> Node:
    return _roles.get(role)
```

### Section 1.4 : Patterns de structure

#### Menu plein ecran
Root type : `Control`

```json
[
  {"name": "Background", "type": "ColorRect"},
  {"name": "Center", "type": "CenterContainer", "children": [
    {"name": "Content", "type": "VBoxContainer", "children": [
      {"name": "Title", "type": "Label"},
      {"name": "Spacer", "type": "Control"},
      {"name": "ButtonGroup", "type": "VBoxContainer", "children": [
        {"name": "PlayButton", "type": "Button"},
        {"name": "OptionsButton", "type": "Button"},
        {"name": "QuitButton", "type": "Button"}
      ]}
    ]}
  ]}
]
```

Apres creation :
```
godot_execute_script(code="""
var root = EditorInterface.get_edited_scene_root()
for child in root.get_children():
	child.set_anchors_preset(Control.PRESET_FULL_RECT)

# Tokens
var content = root.get_node("Center/Content")
content.add_theme_constant_override("separation", 24)

var btn_group = root.get_node("Center/Content/ButtonGroup")
btn_group.add_theme_constant_override("separation", 8)

# Spacer expand
root.get_node("Center/Content/Spacer").size_flags_vertical = Control.SIZE_EXPAND_FILL
root.get_node("Center/Content/Spacer").custom_minimum_size.y = 32

# Roles
root.get_node("Center/Content/Title").set_meta("role", "title")
root.get_node("Center/Content/ButtonGroup/PlayButton").set_meta("role", "play_button")

# Typo
root.get_node("Center/Content/Title").add_theme_font_size_override("font_size", 32)
root.get_node("Center/Content/Title").horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
return "Menu configured"
""")
```

#### HUD overlay
Root : ajouter a une scene de jeu existante.

```json
{"name": "UI", "type": "CanvasLayer", "children": [
  {"name": "HUD", "type": "MarginContainer", "children": [
    {"name": "Layout", "type": "VBoxContainer", "children": [
      {"name": "TopBar", "type": "HBoxContainer", "children": [
        {"name": "HealthBar", "type": "ProgressBar"},
        {"name": "Spacer", "type": "Control"},
        {"name": "ScoreLabel", "type": "Label"}
      ]},
      {"name": "Filler", "type": "Control"},
      {"name": "BottomBar", "type": "HBoxContainer", "children": [
        {"name": "ItemSlots", "type": "HBoxContainer"}
      ]}
    ]}
  ]}
]}
```

Apres creation : anchors full rect sur HUD, `EXPAND_FILL` sur Spacer et Filler, margins `space-lg` (24) sur le MarginContainer.

Roles : `health_bar`, `score_label`, `item_slots`.

#### Dialog box
```json
{"name": "DialogOverlay", "type": "ColorRect", "children": [
  {"name": "Center", "type": "CenterContainer", "children": [
    {"name": "Panel", "type": "PanelContainer", "children": [
      {"name": "Margin", "type": "MarginContainer", "children": [
        {"name": "VBox", "type": "VBoxContainer", "children": [
          {"name": "DialogTitle", "type": "Label"},
          {"name": "DialogText", "type": "RichTextLabel"},
          {"name": "ButtonRow", "type": "HBoxContainer", "children": [
            {"name": "Spacer", "type": "Control"},
            {"name": "CancelButton", "type": "Button"},
            {"name": "ConfirmButton", "type": "Button"}
          ]}
        ]}
      ]}
    ]}
  ]}
]}
```

DialogOverlay : anchors full rect, `color = Color(0, 0, 0, 0.5)` (dimming). Margin : `space-lg` (24). VBox separation : `space-md` (16). ButtonRow Spacer : `EXPAND_FILL`. DialogText : `custom_minimum_size.y = 60`, `fit_content = true`.

Roles : `dialog_title`, `dialog_text`, `cancel_button`, `confirm_button`.

#### Tooltip
```json
{"name": "TooltipPanel", "type": "PanelContainer", "children": [
  {"name": "Margin", "type": "MarginContainer", "children": [
    {"name": "VBox", "type": "VBoxContainer", "children": [
      {"name": "Header", "type": "HBoxContainer", "children": [
        {"name": "Icon", "type": "TextureRect"},
        {"name": "Title", "type": "Label"}
      ]},
      {"name": "Separator", "type": "HSeparator"},
      {"name": "Description", "type": "RichTextLabel"}
    ]}
  ]}
]}
```

Contraintes : `custom_minimum_size.x = 200`, `custom_maximum_size.x = 350`. Description : `fit_content = true`, `bbcode_enabled = true`. Margin : `space-sm` (8). VBox separation : `space-xs` (4). Header separation : `space-xs` (4).

Roles : `tooltip_icon`, `tooltip_title`, `tooltip_description`.

#### Skill/Item slot
```json
{"name": "SkillSlot", "type": "PanelContainer", "children": [
  {"name": "Content", "type": "CenterContainer", "children": [
    {"name": "Icon", "type": "TextureRect"}
  ]},
  {"name": "Keybind", "type": "Label"},
  {"name": "CooldownOverlay", "type": "TextureProgressBar"}
]}
```

Taille fixe : `custom_minimum_size = Vector2(64, 64)`. Icon : centre. Keybind : ancre en bas-droite, `type-caption` (12). CooldownOverlay : anchors full rect, masque radial.

Roles : `skill_icon`, `skill_keybind`, `skill_cooldown`.

#### Hotbar
```json
{"name": "Hotbar", "type": "HBoxContainer", "children": [
  {"name": "Slot1", "type": "PanelContainer", "children": [...]},
  {"name": "Slot2", "type": "PanelContainer", "children": [...]},
  {"name": "Slot3", "type": "PanelContainer", "children": [...]}
]}
```

Separation : `space-xs` (4). Chaque slot = pattern Skill/Item slot ci-dessus. Role collection : `hotbar_slot`.

### Section 1.5 : Layout rules

#### Containers — arbre de decision
```
Besoin de centrer ?
  -> CenterContainer

Besoin de liste verticale (menu, formulaire) ?
  -> VBoxContainer

Besoin de ligne horizontale (toolbar, boutons) ?
  -> HBoxContainer

Besoin de grille (inventaire, level select) ?
  -> GridContainer

Besoin de marges/padding ?
  -> MarginContainer

Besoin d'overlay sur le jeu ?
  -> CanvasLayer > Control (anchors full rect) > layout
```

#### Anchors
- **Uniquement sur les Controls racine** — enfants de Containers n'en ont pas besoin
- Toujours via presets pour la fiabilite :
```
godot_execute_script(code="""
var node = EditorInterface.get_edited_scene_root().get_node("NodePath")
node.set_anchors_preset(Control.PRESET_FULL_RECT)
return "Anchors set"
""")
```
- Presets : `PRESET_TOP_LEFT`, `PRESET_TOP_RIGHT`, `PRESET_BOTTOM_LEFT`, `PRESET_BOTTOM_RIGHT`, `PRESET_CENTER`, `PRESET_FULL_RECT`, `PRESET_LEFT_WIDE`, `PRESET_TOP_WIDE`, `PRESET_RIGHT_WIDE`, `PRESET_BOTTOM_WIDE`, `PRESET_HCENTER_WIDE`, `PRESET_VCENTER_WIDE`

#### Size flags
```
godot_update_property(path="Path/To/Node", property="size_flags_horizontal", value=3)
# 1 = SIZE_FILL, 2 = SIZE_EXPAND, 3 = SIZE_EXPAND_FILL
```

#### Regle d'or
**JAMAIS de position manuelle sur un enfant de Container.** Le Container gere le layout. Point.

### Section 1.6 : Torture tests contenu

Apres construction, verifier CHAQUE composant texte avec ces cas :

| Test | Contenu | Ce qui ne doit PAS arriver |
|------|---------|---------------------------|
| Titre long | 45+ caracteres | Texte qui depasse le conteneur |
| Description longue | 600 caracteres | Texte tronque sans scroll/wrap |
| Langues | FR, DE (mots longs), JA (caracteres larges) | Layout qui casse |
| Valeurs extremes | 0, 9999, 120s, -5 | Debordement numerique |
| Contenu vide | String vide | Trou dans le layout (placeholder requis) |
| Nom joueur | "XXXXXXXXXXXXXXXXXXXXXXXXX" (25 chars) | Depasse son conteneur |

Pour tester via MCP :
```
godot_execute_script(code="""
var label = EditorInterface.get_edited_scene_root().get_node("Path/To/Label")
label.text = "This is a very long title that should test wrapping and truncation behavior"
return "Test content applied"
""")
```

Puis verifier visuellement :
```
godot_get_editor_screenshot
```

### Section 1.7 : Process

1. **Identifier le type d'UI** — menu, HUD, dialog, tooltip, custom ?
2. **Choisir le pattern** de la Section 1.4
3. **Creer la scene** : `godot_create_scene(root_type="Control", path="res://scenes/ui_name.tscn")`
4. **Construire la hierarchie** : `godot_add_nodes` avec le JSON du pattern
5. **Assigner les roles** : `godot_execute_script` pour chaque `set_meta("role", ...)`
6. **Appliquer les tokens** : spacing (`add_theme_constant_override`), radius, typo, couleurs
7. **Verifier** : `godot_get_editor_screenshot`
8. **Torture tests** : appliquer les cas de la Section 1.6
9. **Sauvegarder** : `godot_save_scene`

### Section 1.8 : Accessibilite Composition

| Regle | Detail |
|-------|--------|
| **Double signal** | Ne jamais communiquer par la couleur seule. Coupler avec icone, forme, texte ou opacite |
| **Etats statiques** | Tous les etats doivent etre lisibles sur un screenshot statique, sans animation |
| **Contraste** | `text/primary` >= 4.5:1 sur le fond. `text/secondary` >= 3:1 |
| **Navigation** | Tout element interactif doit etre atteignable au clavier/manette |
| **Focus visible** | Le focus doit avoir un indicateur clair (border, outline) — pas juste un changement subtil |
| **Taille minimum** | Boutons et zones cliquables >= 44px (tactile) ou 32px (souris/manette) |

> **Popup programmatique** — pour les overlays temporaires (level-up, weapon swap, confirmation), preferer `CanvasLayer.new()` + `set_script()` + `_build_ui()` en code plutot qu'un `.tscn`. Un seul `.gd`, pas de scene a maintenir. Pattern complet : voir `references/workflow_patterns.md` S7.

---

## Part 2 — Theme (Design Tokens)

Tu crees un **systeme de design tokens coherent** via la Theme resource de Godot. Pas de valeurs hardcodees dans les nodes — tout passe par le Theme.

### Philosophie

- **Un seul Theme pour les gouverner tous** — pas de `add_theme_*_override` si le Theme peut le gerer
- **Feeling first** — la palette et la typo doivent servir l'emotion du jeu
- **Tokens > valeurs magiques** — chaque valeur a un nom et une raison
- **Shippable** — un theme fonctionnel > un theme parfait jamais applique

### Section 2.1 : Interrogation

Poser ces questions avant de generer quoi que ce soit :

| Question | Impact |
|----------|--------|
| Ambiance visee ? (sombre, clair, neon, pastel, retro...) | Palette de couleurs |
| Genre du jeu ? | Influence le style (pixel art -> pas de radius, sci-fi -> glow, fantasy -> ornements) |
| Palette existante ? (hex, image de reference, rien) | Point de depart couleurs |
| Typographie ? (pixel font, sans-serif propre, serif classique) | Font choice |
| Le jeu a-t-il besoin de plusieurs themes ? (dark/light, par univers) | Architecture Theme |

### Section 2.2 : Generation du Theme

#### 2.2.1 — Creer le Theme resource

```
godot_execute_script(code="""
var theme = Theme.new()

# --- COULEURS ---
# Background
theme.set_color("font_color", "Label", Color("{text_primary}"))
theme.set_color("font_color", "Button", Color("{text_primary}"))
theme.set_color("font_color", "LineEdit", Color("{text_primary}"))

# --- FONT SIZES ---
theme.set_font_size("font_size", "Label", {base_size})
theme.set_font_size("font_size", "Button", {base_size})
theme.set_font_size("font_size", "LineEdit", {base_size})

# --- CONSTANTS (spacing) ---
theme.set_constant("separation", "VBoxContainer", {vbox_sep})
theme.set_constant("separation", "HBoxContainer", {hbox_sep})
theme.set_constant("margin_left", "MarginContainer", {margin})
theme.set_constant("margin_right", "MarginContainer", {margin})
theme.set_constant("margin_top", "MarginContainer", {margin})
theme.set_constant("margin_bottom", "MarginContainer", {margin})

# --- STYLEBOXES ---
# Panel
var panel_style = StyleBoxFlat.new()
panel_style.bg_color = Color("{surface}")
panel_style.corner_radius_top_left = {radius}
panel_style.corner_radius_top_right = {radius}
panel_style.corner_radius_bottom_left = {radius}
panel_style.corner_radius_bottom_right = {radius}
panel_style.content_margin_left = {padding}
panel_style.content_margin_right = {padding}
panel_style.content_margin_top = {padding}
panel_style.content_margin_bottom = {padding}
theme.set_stylebox("panel", "PanelContainer", panel_style)

# Button — normal
var btn_normal = StyleBoxFlat.new()
btn_normal.bg_color = Color("{btn_bg}")
btn_normal.corner_radius_top_left = {radius}
btn_normal.corner_radius_top_right = {radius}
btn_normal.corner_radius_bottom_left = {radius}
btn_normal.corner_radius_bottom_right = {radius}
btn_normal.content_margin_left = {btn_padding_h}
btn_normal.content_margin_right = {btn_padding_h}
btn_normal.content_margin_top = {btn_padding_v}
btn_normal.content_margin_bottom = {btn_padding_v}
theme.set_stylebox("normal", "Button", btn_normal)

# Button — hover
var btn_hover = btn_normal.duplicate()
btn_hover.bg_color = Color("{btn_hover}")
theme.set_stylebox("hover", "Button", btn_hover)

# Button — pressed
var btn_pressed = btn_normal.duplicate()
btn_pressed.bg_color = Color("{btn_pressed}")
theme.set_stylebox("pressed", "Button", btn_pressed)

# Button — disabled
var btn_disabled = btn_normal.duplicate()
btn_disabled.bg_color = Color("{btn_disabled_bg}")
theme.set_color("font_disabled_color", "Button", Color("{text_disabled}"))
theme.set_stylebox("disabled", "Button", btn_disabled)

# Button — focus
var btn_focus = StyleBoxFlat.new()
btn_focus.bg_color = Color.TRANSPARENT
btn_focus.border_color = Color("{accent}")
btn_focus.border_width_bottom = 2
btn_focus.border_width_top = 2
btn_focus.border_width_left = 2
btn_focus.border_width_right = 2
btn_focus.corner_radius_top_left = {radius}
btn_focus.corner_radius_top_right = {radius}
btn_focus.corner_radius_bottom_left = {radius}
btn_focus.corner_radius_bottom_right = {radius}
theme.set_stylebox("focus", "Button", btn_focus)

# LineEdit
var lineedit_normal = StyleBoxFlat.new()
lineedit_normal.bg_color = Color("{input_bg}")
lineedit_normal.border_color = Color("{input_border}")
lineedit_normal.border_width_bottom = 1
lineedit_normal.border_width_top = 1
lineedit_normal.border_width_left = 1
lineedit_normal.border_width_right = 1
lineedit_normal.corner_radius_top_left = {radius}
lineedit_normal.corner_radius_top_right = {radius}
lineedit_normal.corner_radius_bottom_left = {radius}
lineedit_normal.corner_radius_bottom_right = {radius}
lineedit_normal.content_margin_left = {padding}
lineedit_normal.content_margin_right = {padding}
lineedit_normal.content_margin_top = 8
lineedit_normal.content_margin_bottom = 8
theme.set_stylebox("normal", "LineEdit", lineedit_normal)

var lineedit_focus = lineedit_normal.duplicate()
lineedit_focus.border_color = Color("{accent}")
lineedit_focus.border_width_bottom = 2
lineedit_focus.border_width_top = 2
lineedit_focus.border_width_left = 2
lineedit_focus.border_width_right = 2
theme.set_stylebox("focus", "LineEdit", lineedit_focus)

# Sauvegarder
ResourceSaver.save(theme, "res://theme.tres")
return "Theme saved to res://theme.tres"
""")
```

Remplacer les `{placeholders}` par les valeurs concretes choisies avec le user.

#### 2.2.2 — Palette de reference

Voici les tokens couleur a definir. Adapter selon l'ambiance :

| Token | Role | Exemple sombre | Exemple clair |
|-------|------|----------------|---------------|
| `bg` | Fond principal | `#0f0f1a` | `#f5f5f5` |
| `surface` | Panels, cards | `#1a1a2e` | `#ffffff` |
| `surface_variant` | Cards secondaires | `#252540` | `#e8e8e8` |
| `text_primary` | Texte principal | `#e8e8e8` | `#1a1a1a` |
| `text_secondary` | Texte secondaire | `#8888aa` | `#666666` |
| `text_disabled` | Texte inactif | `#555566` | `#aaaaaa` |
| `accent` | Couleur d'accentuation, focus | `#4a9eff` | `#2563eb` |
| `btn_bg` | Bouton repos | `#2a2a4a` | `#e0e0e0` |
| `btn_hover` | Bouton survol | `#3a3a5a` | `#d0d0d0` |
| `btn_pressed` | Bouton appuye | `#1a1a3a` | `#c0c0c0` |
| `btn_disabled_bg` | Bouton desactive | `#1a1a2e` | `#f0f0f0` |
| `input_bg` | Champ de texte | `#151528` | `#ffffff` |
| `input_border` | Bordure input | `#333355` | `#cccccc` |
| `status_success` | Succes | `#4ade80` | `#16a34a` |
| `status_warning` | Attention | `#facc15` | `#ca8a04` |
| `status_error` | Erreur | `#f87171` | `#dc2626` |

#### 2.2.3 — Tokens spacing/radius/typo

| Token | Valeur | Usage |
|-------|--------|-------|
| `space_xs` | 4 | Micro-gaps (icone-texte) |
| `space_sm` | 8 | Gaps internes (padding light) |
| `space_md` | 16 | Separation standard |
| `space_lg` | 24 | Sections |
| `space_xl` | 32 | Grandes separations |
| `space_2xl` | 40 | Marges de page |
| `space_3xl` | 48 | Espacement heroique |
| `radius_sm` | 6 | Petits elements (badges, tags) |
| `radius_md` | 10 | Elements standards (boutons, inputs) |
| `radius_lg` | 14 | Grands elements (cards) |
| `radius_xl` | 18 | Tres grands elements (modals) |
| `type_caption` | 12 | Legendes, hints |
| `type_body` | 16 | Texte courant |
| `type_subtitle` | 18 | Sous-titres |
| `type_big` | 20 | Titres secondaires |
| `type_title` | 24 | Titres |
| `type_hero` | 32 | Titres hero |
| `type_display` | 48 | Elements display |

### Section 2.3 : Application globale

Appliquer le Theme a tout le projet :

```
godot_project_settings(action="set", key="gui/theme/custom", value="res://theme.tres")
```

### Section 2.4 : Fonts

#### Trouver une font
- **Fonts open source** : recommander [freefaces.gallery](https://freefaces.gallery) pour le choix
- **Pixel fonts** : recommander des fonts .ttf pixel art (pas de bitmap)
- La font doit etre copiee dans `res://fonts/`

#### Appliquer une font au Theme
```
godot_execute_script(code="""
var theme = load("res://theme.tres")
var font = load("res://fonts/{font_name}.ttf")
theme.set_font("font", "Label", font)
theme.set_font("font", "Button", font)
theme.set_font("font", "LineEdit", font)
theme.set_font("font", "RichTextLabel", font)
ResourceSaver.save(theme, "res://theme.tres")
return "Font applied to theme"
""")
```

### Section 2.5 : Verification

1. Prendre un screenshot pour verifier :
```
godot_get_editor_screenshot
```

2. Verifier la coherence visuelle :
   - Les couleurs suivent-elles la palette ?
   - Les tailles de texte creent-elles une hierarchie lisible ?
   - Les boutons ont-ils tous leurs etats (normal, hover, pressed, disabled, focus) ?
   - Le contraste text/background est-il suffisant ? (>= 4.5:1 pour le texte principal)

### Section 2.6 : Patterns StyleBox avances

#### Panel avec bordure
```gdscript
var style = StyleBoxFlat.new()
style.bg_color = Color("{surface}")
style.border_color = Color("{input_border}")
style.border_width_bottom = 1
style.border_width_top = 1
style.border_width_left = 1
style.border_width_right = 1
style.corner_radius_top_left = {radius}
style.corner_radius_top_right = {radius}
style.corner_radius_bottom_left = {radius}
style.corner_radius_bottom_right = {radius}
```

#### Panel avec shadow
```gdscript
var style = StyleBoxFlat.new()
style.bg_color = Color("{surface}")
style.shadow_color = Color(0, 0, 0, 0.3)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
```

#### StyleBoxEmpty (pour enlever le style par defaut)
```gdscript
var empty = StyleBoxEmpty.new()
theme.set_stylebox("panel", "PanelContainer", empty)
```

### Section 2.7 : Regles

1. **JAMAIS de `add_theme_*_override` si le Theme peut le gerer** — les overrides sont pour les exceptions, pas la regle
2. **Un token a un nom et une raison** — pas de `Color("#3a7bd5")` en dur, toujours passer par la palette
3. **Tous les etats de Button** — normal, hover, pressed, disabled, focus. Pas d'etat manquant
4. **Focus visible** — le focus doit etre visible au clavier/manette (border accent, pas juste un changement subtil de couleur)
5. **Sauvegarder en `.tres`** — pas en `.res`, pour garder le fichier lisible et diffable
6. **Un Theme par projet** sauf besoin explicite de themes multiples (dark/light, par univers)

---

## Part 3 — Behavior (Feedback & Animation)

Tu donnes **vie** a une UI existante — reactivite, feedback, animations, son. Tu interviens APRES que la composition soit stable (Part 1).

### Entree

Avant d'ajouter du behavior, verifier :

| Artefact | Action |
|----------|--------|
| Roles metadata sur les nodes UI | **Obligatoire.** Lancer `godot_execute_script` pour verifier que les nodes cibles ont des `meta("role")`. Si aucun role -> executer Part 1 d'abord. |
| `theme.tres` | Si present -> les StyleBox gerent deja hover/focus/pressed au niveau theme. Le behavior ajoute les tweens/FX par-dessus, il ne redefinit pas les couleurs. |
| `core/event_bus.gd` | Si present -> les changements de GameState passent par l'EventBus, pas par des appels directs. |

### Philosophie

- **La composition d'abord** — ne pas animer un squelette bancal
- **Feeling first** — chaque transition sert une emotion (satisfaction, urgence, clarte)
- **Interruptible** — toute animation doit pouvoir etre coupee proprement
- **Degradation gracieuse** — le behavior scale de "full FX" a "snap immediat"
- **Shippable sans behavior** — le behavior est du polish, pas un prerequis

### Section 3.1 : Principes de Behavior

| # | Principe | Definition |
|---|----------|------------|
| 1 | **Reactivite** | Feedback immediat a chaque action du joueur (< 100ms percu) |
| 2 | **Navigabilite** | Le focus est toujours visible, la navigation clavier/manette est fluide |
| 3 | **Temporalite** | Les durees de transition sont proportionnelles a l'importance de l'action |
| 4 | **Expressivite** | Les animations ont du caractere (overshoot, squash, ease) pas juste du lineaire |
| 5 | **Coherence** | Meme type d'action = meme type de feedback partout |
| 6 | **Interruptibilite** | Changement d'etat mid-animation = transition propre depuis la valeur actuelle |
| 7 | **Canalisation** | Chaque propriete visuelle est controlee par UN canal d'etat |
| 8 | **Performance** | Les FX se degradent en 3 tiers, jamais de lag perceptible |
| 9 | **Continuite** | Meme en mode degrade, les transitions ponctuelles gardent un fade minimal |

### Section 3.2 : Protocole — 3 canaux d'etats

Le modele d'etats est **orthogonal** : 3 canaux independants combines en un triplet.

#### SystemState (priorite maximale)
| Etat | Signification |
|------|--------------|
| `enabled` | Normal, interactif |
| `disabled` | Grise, non-interactif |
| `hidden` | Invisible, retire du layout |

#### GameState
| Etat | Signification |
|------|--------------|
| `locked` | Verrouille (pas encore debloque) |
| `unlockable` | Condition proche d'etre remplie |
| `active` | Disponible, utilisable |
| `equipped` | Selectionne/equipe |
| `cooldown` | En recharge |

#### InteractionState
| Etat | Signification |
|------|--------------|
| `idle` | Repos |
| `hover` | Souris dessus |
| `focus` | Focus clavier/manette |
| `pressed` | En cours d'appui |

#### Triplet d'etat
Un composant est toujours dans un triplet `(System, Game, Interaction)`.

Exemples :
- `(enabled, active, hover)` -> bouton survole, dispo
- `(enabled, cooldown, idle)` -> skill en recharge, pas d'interaction
- `(disabled, locked, idle)` -> grise et verrouille
- `(enabled, equipped, focus)` -> item equipe, focus manette

#### Regle de dominance
**System > tout.** Si `SystemState = disabled`, les canaux Game et Interaction sont visuellement ecrases (grise). Si `SystemState = hidden`, le node est invisible.

### Section 3.3 : Evenements et transitions

#### Graphe InteractionState
```
idle -> hover    (mouse_enter)
hover -> idle    (mouse_exit)
idle -> focus    (focus_entered, gamepad)
focus -> idle    (focus_exited)
hover -> pressed (mouse_down)
focus -> pressed (accept_pressed)
pressed -> hover (mouse_up, si souris toujours dessus)
pressed -> idle  (mouse_up, si souris sortie)
pressed -> focus (accept_released, gamepad)
```

#### Graphe GameState
```
locked -> unlockable    (condition_progress)
unlockable -> active    (condition_met)
active -> equipped      (equip_action)
equipped -> active      (unequip_action)
active -> cooldown      (use_action)
cooldown -> active      (cooldown_end)
```

#### Graphe SystemState
```
enabled -> disabled    (system_disable)
disabled -> enabled    (system_enable)
enabled -> hidden      (system_hide)
hidden -> enabled      (system_show)
```

#### Loi d'Unicite
Toute transition passe par `emit_event(channel, event)`. **Jamais** de modification directe d'etat.

### Section 3.4 : Canalisation par propriete

Quelle propriete visuelle est controlee par quel canal :

| Propriete | Canal primaire | Canal secondaire | Notes |
|-----------|---------------|-----------------|-------|
| **Scale** | InteractionState | — | Hover: 1.05, Press: 0.95 squash+overshoot |
| **Outline** | InteractionState | — | Focus: border accent visible |
| **Emission/Glow** | GameState (base) | InteractionState (bonus additif) | Equipped: glow, Hover: +glow |
| **Tint/Couleur** | GameState | — | Locked: gris, Cooldown: desature |
| **Masque radial** | GameState | — | Cooldown: fill progressif |
| **Particules** | GameState | InteractionState (additif) | Equipped: aura, Hover: +sparks |
| **Son** | InteractionState (trigger) | GameState (contenu) | Hover->son, mais le son depend du GameState |
| **Opacite** | SystemState | — | Disabled: 0.5, Hidden: 0.0 |

**Regle** : un canal ne touche JAMAIS la propriete d'un autre canal sauf en mode additif explicite.

### Section 3.5 : Implementation GDScript

#### API minimale

```gdscript
## Base class pour tout composant UI avec behavior
extends Control
class_name UIComponent

signal state_changed(channel: String, old_state: String, new_state: String)

var _system_state := "enabled"
var _game_state := "active"
var _interaction_state := "idle"
var _roles := {}
var _active_tweens: Array[Tween] = []

func _ready():
    _cache_roles(self)
    _apply_all_states()

func _cache_roles(node: Node):
    if node.has_meta("role"):
        _roles[node.get_meta("role")] = node
    for child in node.get_children():
        _cache_roles(child)

func get_role(role: String) -> Node:
    return _roles.get(role)

func get_state(channel: String) -> String:
    match channel:
        "system": return _system_state
        "game": return _game_state
        "interaction": return _interaction_state
    return ""

func emit_event(channel: String, event: String):
    var old_state := get_state(channel)
    var new_state := _resolve_transition(channel, old_state, event)
    if new_state == old_state:
        return
    _set_channel(channel, new_state)
    _cleanup_tweens()
    _apply_state(channel, old_state, new_state)
    state_changed.emit(channel, old_state, new_state)

func _set_channel(channel: String, state: String):
    match channel:
        "system": _system_state = state
        "game": _game_state = state
        "interaction": _interaction_state = state

func _resolve_transition(channel: String, current: String, event: String) -> String:
    # Override dans les sous-classes pour definir le graphe
    return current

func _apply_all_states():
    _apply_state("system", "", _system_state)
    _apply_state("game", "", _game_state)
    _apply_state("interaction", "", _interaction_state)

func _apply_state(_channel: String, _old: String, _new: String):
    # Override dans les sous-classes
    pass

func _cleanup_tweens():
    for tween in _active_tweens:
        if tween.is_valid():
            tween.kill()
    _active_tweens.clear()

func _create_tween() -> Tween:
    var tween = create_tween()
    _active_tweens.append(tween)
    return tween
```

#### Pattern tween — hover scale

```gdscript
func _apply_interaction_hover():
    var tween = _create_tween()
    tween.tween_property(self, "scale", Vector2(1.05, 1.05), 0.12).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)

func _apply_interaction_idle():
    var tween = _create_tween()
    tween.tween_property(self, "scale", Vector2.ONE, 0.1).set_ease(Tween.EASE_OUT)
```

#### Pattern tween — press squash + overshoot

```gdscript
func _apply_interaction_pressed():
    var tween = _create_tween()
    # Squash
    tween.tween_property(self, "scale", Vector2(0.92, 0.92), 0.06).set_ease(Tween.EASE_IN)

func _apply_interaction_released():
    var tween = _create_tween()
    # Overshoot bounce
    tween.tween_property(self, "scale", Vector2(1.08, 1.08), 0.1).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
    tween.tween_property(self, "scale", Vector2.ONE, 0.08).set_ease(Tween.EASE_IN_OUT)
```

#### Pattern tween — focus pulsation

```gdscript
func _apply_interaction_focus():
    # Outline visible
    var focus_node = get_role("focus_outline")
    if focus_node:
        focus_node.visible = true

    # Subtle pulse (loop)
    var tween = _create_tween().set_loops()
    tween.tween_property(self, "modulate:a", 0.85, 0.6).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(self, "modulate:a", 1.0, 0.6).set_ease(Tween.EASE_IN_OUT)
```

#### Pattern cooldown — masque radial

```gdscript
func _apply_game_cooldown(duration: float):
    var overlay = get_role("skill_cooldown") as TextureProgressBar
    if not overlay:
        return
    overlay.visible = true
    overlay.value = 100
    overlay.fill_mode = TextureProgressBar.FILL_CLOCKWISE

    var tween = _create_tween()
    tween.tween_property(overlay, "value", 0.0, duration)
    tween.tween_callback(func(): emit_event("game", "cooldown_end"))
```

#### Ecrire via MCP

```
godot_write_script(path="res://scripts/ui_component.gd", content="...")
```

Puis attacher :
```
godot_attach_script(node_path="NodeName", script_path="res://scripts/ui_component.gd")
```

#### Tween pendant pause — 2 conditions obligatoires

> Si l'UI est dans un popup pause (`get_tree().paused = true`), les tweens ne jouent pas par defaut. Il faut les DEUX conditions :

1. `process_mode = Node.PROCESS_MODE_ALWAYS` sur le node qui cree le tween
2. `tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)` sur chaque tween cree

Un seul ne suffit pas. Detail complet : `references/gdscript_pitfalls.md` S11.

### Section 3.6 : Conductor pattern

Un **Conductor** orchestre les comportements d'un contexte UI (un ecran, un panel).

#### Principes
- **Un Conductor par contexte** — pas de God Object qui gere toute l'UI
- **Table de routage declarative** — events -> actions sur les composants
- **Composant absent = skip silencieux** — pas de crash si un node n'existe pas
- **Bus autoload pour inter-contextes** — le Conductor d'un ecran peut envoyer des events au Conductor d'un autre

#### Template

```gdscript
extends Node
class_name UIConductor

## Routing table: {event_name: [{target_role, channel, event}]}
var _routes := {
    "item_selected": [
        {"role": "item_info", "channel": "system", "event": "show"},
        {"role": "equip_button", "channel": "system", "event": "enable"},
    ],
    "item_equipped": [
        {"role": "equipped_icon", "channel": "game", "event": "equip"},
        {"role": "equip_button", "channel": "game", "event": "equipped"},
    ],
}

var _components := {}

func _ready():
    _discover_components(get_parent())

func _discover_components(node: Node):
    if node.has_meta("role"):
        _components[node.get_meta("role")] = node
    for child in node.get_children():
        _discover_components(child)

func dispatch(event_name: String):
    if event_name not in _routes:
        return
    for action in _routes[event_name]:
        var comp = _components.get(action["role"])
        if comp and comp.has_method("emit_event"):
            comp.emit_event(action["channel"], action["event"])
```

### Section 3.7 : Sound design

#### Categories
| Categorie | Role | Priorite |
|-----------|------|----------|
| **Action** | Confirmer, equiper, utiliser | 1 (haute) |
| **Feedback** | Succes, erreur, cooldown fini | 2 |
| **Navigation** | Hover, focus, scroll | 3 |
| **Ambiance** | Musique, ambiance UI | 4 (basse) |

#### Table sonore par evenement
| Evenement | Son type | Duree | Notes |
|-----------|----------|-------|-------|
| hover | tick leger, soft click | < 50ms | Throttle 120ms entre deux triggers |
| focus | note basse, woosh court | < 80ms | Throttle 80ms |
| pressed | click net, impact | < 100ms | Immediat |
| equip | satisfying click, ring | 200-400ms | Feedback clair |
| cooldown_start | descente tonale | 100-200ms | Signale l'indisponibilite |
| cooldown_end | ding, chime | 100-200ms | Signale le retour |
| error | buzz, dissonance | 100-200ms | Pas agressif |
| success | chime ascendant | 200-300ms | Recompense |

#### Throttle anti-fatigue
- `hover` : 120ms minimum entre deux triggers
- `focus` : 80ms minimum
- Max **4 voix simultanees** — si depasse, la voix la plus ancienne est coupee
- **Priorite** : action > feedback > navigation > ambiance

#### Implementation
```gdscript
var _last_sound_time := {}
var _throttle := {"hover": 0.12, "focus": 0.08}

func play_sound(event: String, stream: AudioStream):
    var now = Time.get_ticks_msec() / 1000.0
    var min_interval = _throttle.get(event, 0.0)
    if event in _last_sound_time and now - _last_sound_time[event] < min_interval:
        return
    _last_sound_time[event] = now
    # Play via AudioStreamPlayer
    var player = AudioStreamPlayer.new()
    player.stream = stream
    player.bus = "UI"
    add_child(player)
    player.play()
    player.finished.connect(player.queue_free)
```

### Section 3.8 : Fallback performance (3 tiers)

| Aspect | Tier High | Tier Medium | Tier Low |
|--------|-----------|-------------|----------|
| **Tweens** | Complets (ease, overshoot, bounce) | Simplifies (ease out, pas de bounce) | Snap immediat (spammable) ou fade 60ms (ponctuel) |
| **Particules** | Tous les FX | FX / 2 | Aucun FX |
| **Shaders** | Outline + mask + glow | Outline + mask | Outline seul |
| **Lights** | Toutes | 1 light max | Aucune |
| **Sons** | Tous | Action + Feedback | Muet |

#### Principe de continuite
Meme en **Tier Low**, les transitions **ponctuelles** (show, hide, scene change) conservent un **fade minimal de 60ms**. Seules les transitions **spammables** (hover, focus) sont en snap.

#### Implementation
```gdscript
enum PerformanceTier { HIGH, MEDIUM, LOW }
var current_tier := PerformanceTier.HIGH

func tween_property_tiered(node: Node, property: String, value: Variant, duration_high: float):
    match current_tier:
        PerformanceTier.HIGH:
            var tween = _create_tween()
            tween.tween_property(node, property, value, duration_high).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
        PerformanceTier.MEDIUM:
            var tween = _create_tween()
            tween.tween_property(node, property, value, duration_high * 0.6).set_ease(Tween.EASE_OUT)
        PerformanceTier.LOW:
            node.set(property, value)
```

### Section 3.9 : Process

1. **Verifier la Composition** — la structure UI doit etre stable. Si non, executer Part 1 d'abord
2. **Identifier les composants** qui ont besoin de Behavior (interactifs, animes, feedbacks)
3. **Definir les canaux d'etats** par composant — quel triplet (System, Game, Interaction) ?
4. **Ecrire les graphes de transitions** — quels evenements, quelles transitions autorisees ?
5. **Implementer le script de base** via `godot_write_script` (UIComponent ou derive)
6. **Ajouter les tweens/FX/son** — hover, focus, pressed, transitions d'etat
7. **Tester les interactions** — hover, focus, pressed, transitions rapides
8. **Tester l'interruptibilite** — changer d'etat mid-animation, verifier pas d'empilement
9. **Tester les 3 tiers** — verifier que Tier Low reste utilisable

### Section 3.10 : Accessibilite Behavior

| Regle | Detail |
|-------|--------|
| **Motion reduite** | Toutes transitions = snap, pas de pulsation, pas de loop. Respecter la preference OS |
| **Son coupe** | Tout feedback sonore a un equivalent visuel (flash, outline, changement de couleur) |
| **Focus manette** | Le focus est TOUJOURS visible — outline franche, pas juste un glow subtil |
| **Pas de clignotement** | Aucune animation > 3 flash/seconde (risque epilepsie) |
| **Timing** | Les animations ne bloquent jamais l'interaction — le joueur peut agir pendant un tween |

### Section 3.11 : Game Feel / Juice

> **Ce skill se concentre sur le behavior UI.** Pour le game feel non-UI (screenshake, freeze frame, squash & stretch, trails, damage numbers, etc.), voir **`skills/juice.md`**.

Le skill `skills/juice.md` couvre :
- Screenshake avec Perlin noise
- Freeze frames / hitstop
- Squash & stretch procedural
- Hit flash (shader ou modulate)
- Trails (Line2D avec top_level)
- Damage numbers
- Autoload Juice.gd

Pour les particules avancees, shaders, et lighting, voir **`skills/vfx.md`**.

### Section 3.12 : Review checklist (UI Behavior)

Quand tu reviews du code UI behavior, verifie systematiquement :

| Pattern | Question | Fix si non |
|---------|----------|------------|
| **Tweens** | Cleanup des tweens precedents avant d'en creer un nouveau ? | Appeler `_cleanup_tweens()` ou `tween.kill()` |
| **Etats** | Les 3 canaux (System/Game/Interaction) sont definis ? | Declarer les etats par canal |
| **Canalisation** | Chaque propriete a UN canal proprietaire ? | Verifier la table de canalisation |
| **Interruptibilite** | Changement d'etat mid-animation = transition propre ? | Tester les transitions rapides |
| **Idempotence** | Appliquer le meme etat 2x = pas d'empilement ? | Verifier que les tweens ne s'accumulent pas |
| **Focus** | Outline visible pour navigation manette ? | Ajouter outline_width > 0 sur focus |
| **Son** | Throttle en place pour hover/focus ? | 80-120ms entre deux triggers |

Pour la checklist game feel (screenshake, trails, etc.), voir `skills/juice.md` Section 10.

### Rappels GDScript

- **`:=` interdit sur sources Variant** — `var x: Type = expression` si la source est Array, Dict, ternaire, `.duplicate()`
- **Tween sur node freed = crash** — utiliser `get_tree().create_tween()` si le node peut etre `queue_free()`
- **Tween pendant pause = 2 conditions** — `process_mode = PROCESS_MODE_ALWAYS` sur le node ET `tw.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)` sur le tween
- **Signaux = synchrones** — `emit()` execute tous les handlers immediatement avant la ligne suivante
- **`is_instance_valid(node)`** avant de manipuler un node dans un callback signal

---

## See also

| Skill | Quand l'utiliser |
|-------|-----------------|
| `skills/juice.md` | Game feel gameplay (non-UI) — screenshake, freeze, squash |
| `skills/audio.md` | Sound design integre — audio buses, SFX pool |
| `skills/scene.md` | En amont — Construction de la scene contenant l'UI |
| `skills/debug.md` | Si l'UI a des problemes visuels ou de comportement |
| `/fix` | En aval — diagnostic si l'UI a des problemes visuels ou de layout |
