dd-timer/docs/plans/2026-04-04-accouplement-redesign.md
POL Mickaël 3e485fd09b chore: normalise fins de ligne CRLF → LF dans tout le repo
Applique .gitattributes sur tous les fichiers existants.
Élimine les différences fantômes entre WSL et Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 08:55:10 +02:00

27 KiB
Executable File
Raw Blame History

Refonte Graphique — Écran Accouplement

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Refondre l'écran d'accouplement pour passer d'un wizard séquentiel à un layout single-page avec panneaux Parent 1 / Centre / Parent 2 + grille de races, style Obsidienne glassmorphism.

Architecture: Réécriture complète du HTML généré par AccouplementView.ts + remplacement des styles .accoup-* dans components.css par de nouveaux styles dans obsidienne.css. La logique métier (imports Race, CommandBus) reste identique.

Tech Stack: TypeScript, CSS (tokens Obsidienne/MD3), Material Symbols Outlined, getDDImage() helper existant.


Task 1 : Nouveaux styles CSS Obsidienne pour l'accouplement

Files:

  • Modify: src/presentation/styles/obsidienne.css (ajouter en fin de fichier)
  • Modify: src/presentation/styles/components.css:314-401 (supprimer les anciens styles .accoup-*)

Contexte: La maquette utilise du glassmorphism (fond semi-transparent + backdrop-blur + bordure purple subtile), des rounded-2xl (16px), des chips pill pour les générations, et un layout grid 12 colonnes. On traduit tout ça en CSS vanilla avec les tokens --md-* déjà définis dans variables.css.

Step 1 : Supprimer les anciens styles accouplement de components.css

Supprimer les lignes 314-331 et 401 de components.css (tout ce qui commence par .accoup- et .accouplement-view).

Step 2 : Ajouter les nouveaux styles dans obsidienne.css

Ajouter en fin de fichier :

/* ── Accouplement View ── */
.accoup-view {
  display: flex;
  flex-direction: column;
  gap: 24px;
  padding: 8px;
}

/* Parent Selection Grid (3 columns: parent1 | center | parent2) */
.accoup-parents {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  gap: 24px;
  align-items: start;
}

.accoup-parent-section {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.accoup-parent-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  padding: 0 8px;
}

.accoup-parent-title {
  font-family: 'Manrope', sans-serif;
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--md-on-surface);
  text-transform: uppercase;
  letter-spacing: -0.03em;
}

.accoup-gender-badge {
  font-size: 10px;
  font-weight: 700;
  padding: 2px 8px;
  border-radius: 4px;
}
.accoup-gender-badge.male {
  color: var(--md-secondary);
  background: rgba(134, 20, 90, 0.2);
}
.accoup-gender-badge.female {
  color: var(--md-primary);
  background: rgba(193, 133, 253, 0.2);
}

/* Placeholder card (empty parent slot) */
.accoup-placeholder {
  height: 180px;
  border-radius: 16px;
  background: rgba(23, 23, 33, 0.6);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(168, 85, 247, 0.15);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.2s;
}
.accoup-placeholder:hover {
  background: rgba(255, 255, 255, 0.07);
}

.accoup-placeholder-inner {
  text-align: center;
}

.accoup-placeholder-icon {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.05);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 10px;
  transition: transform 0.2s;
}
.accoup-placeholder:hover .accoup-placeholder-icon {
  transform: scale(1.1);
}
.accoup-placeholder-icon .material-symbols-outlined {
  font-size: 28px;
  opacity: 0.4;
}

.accoup-placeholder-text {
  font-size: 11px;
  color: var(--md-on-surface-variant);
}

/* Selected parent card (replacing placeholder) */
.accoup-selected-parent {
  height: 180px;
  border-radius: 16px;
  background: rgba(23, 23, 33, 0.6);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(168, 85, 247, 0.3);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  position: relative;
  cursor: pointer;
}
.accoup-selected-parent:hover {
  border-color: rgba(168, 85, 247, 0.5);
}
.accoup-selected-parent .race-card-avatar {
  width: 64px;
  height: 64px;
}
.accoup-selected-parent .race-card-avatar img {
  width: 64px;
  height: 64px;
}
.accoup-selected-parent-name {
  font-size: 13px;
  font-weight: 700;
  color: var(--md-on-surface);
}
.accoup-selected-parent-badge {
  font-size: 9px;
  font-weight: 700;
  padding: 2px 8px;
  border-radius: 6px;
  color: #fff;
}
.accoup-selected-parent-clear {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.08);
  border: none;
  color: var(--md-on-surface-variant);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  transition: background 0.2s;
}
.accoup-selected-parent-clear:hover {
  background: rgba(255, 110, 132, 0.2);
  color: var(--md-error);
}

/* Center pairing column */
.accoup-center {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 16px 0;
  min-width: 160px;
}

.accoup-heart {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--md-primary), var(--md-secondary));
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4px 16px rgba(203, 151, 255, 0.2);
}
.accoup-heart .material-symbols-outlined {
  color: #000;
  font-size: 22px;
}

.accoup-center-inputs {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: 100%;
}

.accoup-center-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.accoup-center-label {
  display: block;
  font-size: 10px;
  font-family: 'Manrope', sans-serif;
  font-weight: 700;
  color: var(--md-on-surface-variant);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  text-align: center;
}

.accoup-center-input {
  width: 100%;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  padding: 8px 12px;
  text-align: center;
  font-size: 14px;
  font-weight: 700;
  color: var(--md-primary);
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
}
.accoup-center-input:focus {
  border-color: var(--md-primary);
  box-shadow: 0 0 0 1px var(--md-primary);
}
.accoup-center-input.secondary {
  color: var(--md-secondary);
}
.accoup-center-input.secondary:focus {
  border-color: var(--md-secondary);
  box-shadow: 0 0 0 1px var(--md-secondary);
}

.accoup-register-btn {
  padding: 10px 24px;
  background: linear-gradient(135deg, var(--md-primary), var(--md-primary-container));
  color: var(--md-on-primary);
  font-family: 'Manrope', sans-serif;
  font-weight: 800;
  font-size: 11px;
  border: none;
  border-radius: 9999px;
  cursor: pointer;
  box-shadow: 0 4px 16px rgba(203, 151, 255, 0.3);
  transition: transform 0.1s, box-shadow 0.2s;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.accoup-register-btn:hover {
  box-shadow: 0 6px 20px rgba(203, 151, 255, 0.4);
}
.accoup-register-btn:active {
  transform: scale(0.98);
}
.accoup-register-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* Grid panel (glass) */
.accoup-grid-panel {
  background: rgba(23, 23, 33, 0.6);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(168, 85, 247, 0.15);
  border-radius: 24px;
  padding: 24px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

/* Gen chips */
.accoup-gen-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding-bottom: 20px;
  margin-bottom: 24px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.accoup-gen-chips-label {
  font-size: 10px;
  font-weight: 700;
  color: var(--md-on-surface-variant);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-right: 12px;
}

.accoup-gen-chip {
  padding: 6px 16px;
  border-radius: 9999px;
  font-size: 11px;
  font-weight: 700;
  cursor: pointer;
  border: none;
  background: rgba(255, 255, 255, 0.05);
  color: var(--md-on-surface-variant);
  transition: background 0.2s, color 0.2s;
}
.accoup-gen-chip:hover {
  color: var(--md-on-surface);
  background: rgba(255, 255, 255, 0.1);
}
.accoup-gen-chip.active {
  background: var(--md-primary);
  color: var(--md-on-primary);
}

/* Search (kept from existing, restyled) */
.accoup-search-wrap {
  position: relative;
  display: flex;
  align-items: center;
  margin-bottom: 20px;
}
.accoup-search {
  flex: 1;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  color: var(--md-on-surface);
  padding: 10px 40px 10px 14px;
  font-size: 13px;
  font-weight: 600;
  outline: none;
  transition: border-color 0.2s;
}
.accoup-search:focus {
  border-color: var(--md-primary);
}
.accoup-search-clear {
  position: absolute;
  right: 10px;
  background: none;
  border: none;
  color: var(--md-on-surface-variant);
  cursor: pointer;
  font-size: 16px;
  padding: 4px;
  transition: color 0.15s;
}
.accoup-search-clear:hover {
  color: var(--md-on-surface);
}

/* Dragon cards grid */
.accoup-race-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap: 12px;
}

.accoup-race-card {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 16px;
  padding: 10px;
  border: 1px solid rgba(255, 255, 255, 0.05);
  cursor: pointer;
  transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
  text-align: center;
}
.accoup-race-card:hover {
  border-color: rgba(203, 151, 255, 0.4);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  transform: translateY(-2px);
}
.accoup-race-card.selecting {
  border-color: rgba(203, 151, 255, 0.15);
}
.accoup-race-card.selecting:hover {
  border-color: rgba(203, 151, 255, 0.5);
}

.accoup-race-card-img {
  position: relative;
  aspect-ratio: 1;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.05);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  margin-bottom: 8px;
}
.accoup-race-card-img .race-card-avatar {
  width: 56px;
  height: 56px;
  transition: transform 0.2s;
}
.accoup-race-card:hover .race-card-avatar {
  transform: scale(1.1);
}
.accoup-race-card-img .race-card-avatar img {
  width: 56px;
  height: 56px;
}

.accoup-race-card-gen {
  position: absolute;
  top: 4px;
  right: 4px;
  padding: 2px 6px;
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(4px);
  font-size: 8px;
  font-weight: 700;
  color: var(--md-tertiary, #ffe083);
}

.accoup-race-card-name {
  font-size: 11px;
  font-weight: 700;
  color: var(--md-on-surface);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.accoup-race-card-info {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 4px;
}

.accoup-race-card-sub {
  font-size: 9px;
  color: var(--md-on-surface-variant);
}

/* Empty state */
.accoup-empty {
  text-align: center;
  color: var(--md-on-surface-variant);
  padding: 40px;
  font-style: italic;
  font-size: 13px;
}

Step 3 : Vérifier le build

Run: npm run build (depuis Windows) Expected: Build réussi sans erreur CSS.

Step 4 : Commit

git add src/presentation/styles/obsidienne.css src/presentation/styles/components.css
git commit -m "refactor(accouplement): remplace anciens styles par design Obsidienne glassmorphism"

Task 2 : Réécriture du HTML — layout single-page avec panneaux parents + grille

Files:

  • Modify: src/presentation/components/AccouplementView.ts (réécriture complète du rendering)

Contexte: On remplace le wizard 3 étapes par un layout unique :

  • Haut : grille 3 colonnes (Parent 1 | Centre cœur+inputs+register | Parent 2)
  • Bas : panneau glass avec chips génération + recherche + grille de races
  • Quand on clique une race, elle remplit le slot parent approprié (P1 si vide, sinon P2)
  • Quand les 2 parents sont sélectionnés, le bouton Enregistrer devient actif

Step 1 : Réécrire le state et la structure

Remplacer l'interface AccoupState et toute la classe :

import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_BY_PARENTS, COMPATIBLE_PARTNERS, raceColor } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';

interface AccoupState {
  parent1: string | null;
  parent2: string | null;
  filterGen: number | null;
  search: string;
  couples: string;
  babies: string;
  selectingSlot: 1 | 2;
}

export class AccouplementView {
  private el: HTMLElement | null = null;
  private accoupState: AccoupState = {
    parent1: null, parent2: null,
    filterGen: null, search: '',
    couples: '', babies: '',
    selectingSlot: 1,
  };
  private dirty = true;

  constructor(
    private commandBus: CommandBus,
    private queryBus: QueryBus,
  ) {}

  render(container: HTMLElement): void {
    this.el = document.createElement('div');
    this.el.className = 'accoup-view';
    container.appendChild(this.el);
    this.dirty = true;
    this.update();
  }

  update(): void {
    if (!this.el || !this.dirty) return;
    this.dirty = false;
    this.renderSinglePage();
  }

  destroy(): void {
    this.el?.remove();
    this.el = null;
  }

  /* ── All races list (cached) ── */
  private getAllRaces(): { name: string; gen: number }[] {
    const all: { name: string; gen: number }[] = [];
    for (const base of ['Rousse', 'Amande', 'Dorée'])
      all.push({ name: base, gen: 1 });
    for (const [g, rs] of Object.entries(RACES_DATA))
      for (const r of rs)
        all.push({ name: r.name, gen: parseInt(g) });
    return all;
  }

  /* ── Filtered races based on gen filter, search, and partner compatibility ── */
  private getFilteredRaces(): { name: string; gen: number }[] {
    const { filterGen, search, parent1, selectingSlot } = this.accoupState;
    let races = this.getAllRaces();

    // If selecting parent 2, only show compatible partners
    if (selectingSlot === 2 && parent1) {
      const partners = COMPATIBLE_PARTNERS[parent1] ?? [];
      const partnerNames = new Set(partners.map(p => p.partner));
      races = races.filter(r => partnerNames.has(r.name));
    }

    const q = search.trim().toLowerCase();
    return races.filter(r =>
      (filterGen ? r.gen === filterGen : true) &&
      (q ? r.name.toLowerCase().includes(q) : true)
    );
  }

  /* ── Baby name from parents ── */
  private getBabyName(): string | null {
    const { parent1, parent2 } = this.accoupState;
    if (!parent1 || !parent2) return null;
    return BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? null;
  }

  /* ── Single page render ── */
  private renderSinglePage(): void {
    if (!this.el) return;
    const { parent1, parent2, filterGen, search, couples, babies, selectingSlot } = this.accoupState;
    const baby = this.getBabyName();
    const hasBoth = !!(parent1 && parent2 && baby);

    let html = '';

    /* ── Parent panels row ── */
    html += `<div class="accoup-parents">`;

    // Parent 1 section
    html += `<section class="accoup-parent-section">`;
    html += `<div class="accoup-parent-header">
      <span class="accoup-parent-title">Parent 1</span>
      <span class="accoup-gender-badge male">MÂLE REQUIS</span>
    </div>`;
    if (parent1) {
      const gen1 = RACE_GEN[parent1] ?? 1;
      html += `<div class="accoup-selected-parent" data-clear="1">
        ${getDDImage(parent1)}
        <span class="accoup-selected-parent-name">${esc(parent1)}</span>
        <span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen1] ?? '#888'}">Gen ${gen1}</span>
        <button class="accoup-selected-parent-clear" data-clear="1" title="Retirer">
          <span class="material-symbols-outlined" style="font-size:14px">close</span>
        </button>
      </div>`;
    } else {
      html += `<div class="accoup-placeholder" data-select-slot="1">
        <div class="accoup-placeholder-inner">
          <div class="accoup-placeholder-icon">
            <span class="material-symbols-outlined" style="color:var(--md-primary)">add</span>
          </div>
          <p class="accoup-placeholder-text">Cliquer pour choisir un mâle</p>
        </div>
      </div>`;
    }
    html += `</section>`;

    // Center column
    html += `<div class="accoup-center">`;
    html += `<div class="accoup-heart">
      <span class="material-symbols-outlined mso-fill">favorite</span>
    </div>`;
    html += `<div class="accoup-center-inputs">
      <div class="accoup-center-field">
        <label class="accoup-center-label">Nombre de couples</label>
        <input class="accoup-center-input" id="accoup-couples" type="number" min="1" value="${esc(couples)}" placeholder="1">
      </div>
      <div class="accoup-center-field">
        <label class="accoup-center-label">Bébés obtenus</label>
        <input class="accoup-center-input secondary" id="accoup-babies" type="number" min="0" value="${esc(babies)}" placeholder="0">
      </div>
    </div>`;

    // Baby preview (if both parents selected)
    if (hasBoth && baby) {
      const babyGen = RACE_GEN[baby] ?? 0;
      html += `<div style="text-align:center;margin-top:4px">
        <div style="font-size:10px;color:var(--md-on-surface-variant);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:6px">Résultat</div>
        ${getDDImage(baby)}
        <div style="font-size:12px;font-weight:700;color:var(--md-on-surface);margin-top:4px">${esc(baby)}</div>
        <span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[babyGen] ?? '#888'};font-size:8px">Gen ${babyGen}</span>
      </div>`;
    }

    html += `<button class="accoup-register-btn" id="accoup-register" ${hasBoth ? '' : 'disabled'}>ENREGISTRER</button>`;
    html += `</div>`;

    // Parent 2 section
    html += `<section class="accoup-parent-section">`;
    html += `<div class="accoup-parent-header">
      <span class="accoup-parent-title">Parent 2</span>
      <span class="accoup-gender-badge female">FEMELLE REQUISE</span>
    </div>`;
    if (parent2) {
      const gen2 = RACE_GEN[parent2] ?? 1;
      html += `<div class="accoup-selected-parent" data-clear="2">
        ${getDDImage(parent2)}
        <span class="accoup-selected-parent-name">${esc(parent2)}</span>
        <span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen2] ?? '#888'}">Gen ${gen2}</span>
        <button class="accoup-selected-parent-clear" data-clear="2" title="Retirer">
          <span class="material-symbols-outlined" style="font-size:14px">close</span>
        </button>
      </div>`;
    } else {
      html += `<div class="accoup-placeholder" data-select-slot="2">
        <div class="accoup-placeholder-inner">
          <div class="accoup-placeholder-icon">
            <span class="material-symbols-outlined" style="color:var(--md-secondary)">add</span>
          </div>
          <p class="accoup-placeholder-text">Cliquer pour choisir une femelle</p>
        </div>
      </div>`;
    }
    html += `</section>`;
    html += `</div>`; // .accoup-parents

    /* ── Grid panel ── */
    html += `<div class="accoup-grid-panel">`;

    // Gen chips
    html += `<div class="accoup-gen-chips">`;
    html += `<span class="accoup-gen-chips-label">Générations</span>`;
    html += `<button class="accoup-gen-chip${filterGen === null ? ' active' : ''}" data-gen="all">Toutes</button>`;
    for (let g = 1; g <= 10; g++) {
      html += `<button class="accoup-gen-chip${filterGen === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
    }
    html += `</div>`;

    // Search
    html += `<div class="accoup-search-wrap">
      <input class="accoup-search" id="accoup-search-input" type="text"
        placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
      ${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
    </div>`;

    // Race grid
    const filtered = this.getFilteredRaces();
    if (filtered.length === 0) {
      html += `<div class="accoup-empty">Aucune race trouvée</div>`;
    } else {
      const selecting = (!parent1 || !parent2);
      html += `<div class="accoup-race-grid">`;
      for (const race of filtered) {
        const genCol = GEN_COLORS[race.gen] ?? '#888';
        html += `<div class="accoup-race-card${selecting ? ' selecting' : ''}" data-race="${esc(race.name)}">
          <div class="accoup-race-card-img">
            ${getDDImage(race.name)}
            <div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
          </div>
          <div class="accoup-race-card-name">${esc(race.name)}</div>
        </div>`;
      }
      html += `</div>`;
    }

    html += `</div>`; // .accoup-grid-panel

    this.el.innerHTML = html;
    this.bindEvents();

    // Restore search focus if active
    if (search) {
      const inp = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
      if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
    }
  }

  /* ── Event binding ── */
  private bindEvents(): void {
    if (!this.el) return;

    // Gen chips
    this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
      btn.addEventListener('click', () => {
        const val = (btn as HTMLElement).dataset.gen;
        this.accoupState.filterGen = val === 'all' ? null : parseInt(val!);
        this.dirty = true; this.update();
      });
    });

    // Search
    const searchInput = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
    if (searchInput) {
      searchInput.addEventListener('input', () => {
        this.accoupState.search = searchInput.value;
        this.dirty = true; this.update();
      });
    }
    const clearBtn = this.el.querySelector('.accoup-search-clear');
    if (clearBtn) {
      clearBtn.addEventListener('click', () => {
        this.accoupState.search = '';
        this.dirty = true; this.update();
      });
    }

    // Placeholder clicks (select slot)
    this.el.querySelectorAll<HTMLElement>('[data-select-slot]').forEach(ph => {
      ph.addEventListener('click', () => {
        this.accoupState.selectingSlot = parseInt(ph.dataset.selectSlot!) as 1 | 2;
        // No re-render needed, just changes which slot gets filled
      });
    });

    // Clear parent buttons
    this.el.querySelectorAll<HTMLElement>('[data-clear]').forEach(btn => {
      if (btn.tagName === 'BUTTON') {
        btn.addEventListener('click', (e) => {
          e.stopPropagation();
          const slot = parseInt(btn.dataset.clear!);
          if (slot === 1) {
            this.accoupState.parent1 = null;
            this.accoupState.parent2 = null; // Reset P2 too since partners depend on P1
            this.accoupState.selectingSlot = 1;
          } else {
            this.accoupState.parent2 = null;
            this.accoupState.selectingSlot = 2;
          }
          this.dirty = true; this.update();
        });
      }
    });

    // Race card clicks
    this.el.querySelectorAll<HTMLElement>('.accoup-race-card').forEach(card => {
      card.addEventListener('click', () => {
        const race = card.dataset.race!;
        if (!this.accoupState.parent1) {
          this.accoupState.parent1 = race;
          this.accoupState.selectingSlot = 2;
          this.accoupState.filterGen = null;
          this.accoupState.search = '';
        } else if (!this.accoupState.parent2) {
          this.accoupState.parent2 = race;
        }
        this.dirty = true; this.update();
      });
    });

    // Couples / babies inputs
    const couplesInput = this.el.querySelector<HTMLInputElement>('#accoup-couples');
    const babiesInput = this.el.querySelector<HTMLInputElement>('#accoup-babies');
    if (couplesInput) {
      let prev = couplesInput.value;
      couplesInput.addEventListener('focus', () => { prev = couplesInput.value; couplesInput.value = ''; });
      couplesInput.addEventListener('blur', () => {
        if (couplesInput.value === '') couplesInput.value = prev;
        this.accoupState.couples = couplesInput.value;
      });
    }
    if (babiesInput) {
      let prev = babiesInput.value;
      babiesInput.addEventListener('focus', () => { prev = babiesInput.value; babiesInput.value = ''; });
      babiesInput.addEventListener('blur', () => {
        if (babiesInput.value === '') babiesInput.value = prev;
        this.accoupState.babies = babiesInput.value;
      });
    }

    // Register button
    const registerBtn = this.el.querySelector('#accoup-register');
    if (registerBtn) {
      registerBtn.addEventListener('click', () => {
        const { parent1, parent2 } = this.accoupState;
        if (!parent1 || !parent2) return;
        const baby = BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? '';
        if (!baby) return;
        const c = parseInt(this.accoupState.couples) || 0;
        const b = parseInt(this.accoupState.babies) || 0;
        if (c <= 0) return;

        this.commandBus.execute({
          type: 'register-accouplement',
          parent1,
          parent2,
          baby,
          gen: RACE_GEN[baby] ?? 0,
          couples: c,
          babiesObtained: b,
        });

        // Reset
        this.accoupState = {
          parent1: null, parent2: null,
          filterGen: null, search: '',
          couples: '', babies: '',
          selectingSlot: 1,
        };
        this.dirty = true; this.update();
      });
    }
  }
}

Step 2 : Vérifier la compilation

Run: npm run build (depuis Windows) Expected: Build réussi, pas d'erreurs TypeScript.

Step 3 : Tester visuellement

Run: npm start

  • Naviguer vers l'écran Accouplement
  • Vérifier le layout 3 colonnes (Parent 1 | Cœur+inputs | Parent 2)
  • Cliquer une race → remplit Parent 1
  • La grille se filtre pour montrer les partenaires compatibles
  • Cliquer un partenaire → remplit Parent 2
  • Le bébé apparaît au centre + bouton Enregistrer actif
  • Le bouton × vide un parent
  • Les chips génération et la recherche fonctionnent

Step 4 : Commit

git add src/presentation/components/AccouplementView.ts
git commit -m "feat(accouplement): refonte layout single-page Obsidienne avec panneaux parents"

Task 3 : Nettoyage et polish

Files:

  • Modify: src/presentation/styles/components.css (vérifier qu'il ne reste rien d'accoup)
  • Verify: src/presentation/components/AccouplementView.ts

Step 1 : Vérifier qu'aucun ancien style .accoup-* ne reste dans components.css

Chercher accoup dans components.css. Si des restes existent, les supprimer.

Step 2 : Vérifier que le flow complet fonctionne

Test manuel :

  1. Sélectionner "Rousse" → P1 se remplit, grille montre partenaires compatibles
  2. Sélectionner un partenaire → P2 se remplit, bébé apparaît au centre
  3. Entrer couples=2, bébés=1
  4. Cliquer "Enregistrer" → reset complet
  5. Bouton × sur P1 → reset P1 et P2
  6. Bouton × sur P2 → reset P2 seulement
  7. Filtrer par Gen 2 → seules les races Gen 2 apparaissent
  8. Rechercher "Rou" → filtre textuel fonctionne

Step 3 : Commit final

git add -A
git commit -m "chore(accouplement): nettoyage styles et polish refonte"