Konubinix' opinionated web of thoughts

Scrutin De Condorcet Randomisé Entre Amis

Braindump

tl;dr: https://sam.konubinix.eu/condorcet/

Pitch et contraintes

Voter à la majorité paraît simple, mais dès qu’il y a plus de deux options la majorité se perd dans les paradoxes. Le scrutin de Condorcet répare ça en regardant chaque duel entre candidats. Quand il n’y a pas de vainqueur net (un cycle), on départage par un tirage au sort dans le Smith set (la short-list des candidats encore en course après élimination des dominés) — reproductible, parce que la graine est le hash des bulletins clos, pas un appel serveur.

Cette app est un outil de vote entre amis. Ce n’est pas un système officiel : on peut bidouiller le bulletin du copain en inspectant IndexedDB. Ça suffit pour un apéro ou un choix de film en famille, et ça tient dans un seul fichier HTML.

Les deux modes d’utilisation

L’app supporte deux modes de vote, au choix à la création du scrutin :

  1. Appareil partagé : tout le monde vote sur le même téléphone. Entre deux votes, l’écran montre la liste des votants ; chacun choisit son nom, classe, soumet, et son nom passe à coché « a voté » avant de tendre l’appareil au suivant. L’ordre n’est pas imposé, et la liste — noms et coches uniquement — ne révèle rien des classements déjà soumis : elle sert d’écran-tampon naturel.
  2. Un appareil par personne : chacun ouvre le lien du scrutin sur son téléphone et vote séparément. La sync est assurée par le serveur automergesync via WebSocket Automerge. La même liste sert d’écran d’identification, à ceci près que l’identité est mémorisée en localStorage scopée à l’URL du doc : un reload ne redemande pas qui on est.

Le mode se choisit à la création et conditionne le flux de vote.

Secret du bulletin

Règle dure : pendant qu’un votant classe ses candidats, il ne voit aucun bulletin déjà soumis. Deux conséquences concrètes :

  • En mode appareil partagé, la liste des candidats à classer est réinitialisée et mélangée pour chaque votant, pour qu’on ne puisse pas reconstruire l’ordre soumis par le précédent à partir de la position initiale.
  • L’écran de vote n’affiche jamais la matrice des duels, les totaux partiels, ni la liste des bulletins. Ces vues n’apparaissent qu’après la clôture du scrutin.

Le compteur “n votants / m inscrits” et la liste des voters ayant déjà voté sont considérés comme non-secrets : ils n’indiquent pas comment ils ont voté. C’est l’opacité sociale minimale acceptable pour un vote entre amis.

Bibliothèques externes

On ne réinvente aucune roue. Les briques tierces, déclarées dans package.json et bundlées par rspack (cf. Bundler avec rspack) :

  • petite-vue — réactivité de l’UI, directives HTML v-show, v-for, v-model, @click. Pas de build step côté framework, ~6 KB.

  • Automerge 2 + automerge-repo 1 + automerge-repo-storage-indexeddb

    • automerge-repo-network-websocket — CRDT local-first,

    persistance IndexedDB, sync WebSocket vers le serveur automergesync. Un scrutin = un doc Automerge, identifié par son URL automerge:<hash>, partageable comme lien d’invitation.

  • qrcode-generator — génération du QR pour partage en face-à-face.

  • API Web Crypto native — hash SHA-256 pour la graine de tie-break.

Le drag-and-drop est custom (pointer events purs), pas de lib — voir shared_blocks.org pour le moteur partagé.

Le service worker et le manifest PWA sont définis inline dans ce fichier plutôt que via des blocs partagés, parce que leur contenu (nom, couleurs, icône, liste d’assets à cacher) est spécifique à l’app.

Fondations de base (Ouvrir, créer, partager)

Ouvrir l’app

Sans scrutin en cours, il n’y a rien à montrer. Afficher une interface vide ou des contrôles inutiles désoriente. Un seul écran d’entrée — titre, urne, un bouton — et l’utilisateur sait immédiatement quoi faire.

<div class="empty-state" id="emptyState" v-show="!scrutin && !loadingDoc" v-cloak>
  <div class="emoji">&#x1f5f3;</div>
  <h1>Scrutin de Condorcet</h1>
  <p>Aucun scrutin en cours.</p>
  <button class="cta" id="btnOpenCreate" @click="openCreate()">Créer un scrutin</button>
</div>

.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}
.empty-state .emoji{font-size:3rem;margin-bottom:12px}
.empty-state h1{font-size:1.4rem;margin:0 0 8px 0;color:var(--fg)}
.empty-state p{font-size:.95rem;line-height:1.5;margin-bottom:24px}

Liste des scrutins précédents

On revient le lendemain pour rejouer un vote, ou pour relire le résultat d’un scrutin clos — mais l’URL automerge:... est introuvable dans l’historique de navigation, perdue dans la conversation Signal où on l’avait reçue. Le doc, lui, est en IndexedDB sur cet appareil. On en liste donc les passages depuis l’empty-state, ordonnés du plus récent au plus ancien : un clic re-ouvre le scrutin via son URL ?doc…=, sans recharger autre chose que ce qui est déjà en SW cache.

Les entrées vivent en localStorage (condorcet.past-scrutins, tableau de {url, title, at}) parce que c’est local par appareil par essence : ce que ce navigateur a vu, pas ce que tous les utilisateurs du scrutin ont vu. Le doc Automerge reste source de vérité pour le contenu ; le titre y est juste capturé au passage pour libeller la liste.

Un × à droite de chaque ligne retire l’entrée de la liste sans toucher au doc en IndexedDB — l’utilisateur peut toujours revenir au scrutin s’il en retrouve l’URL ailleurs.

« Ailleurs » est une promesse fragile : l’URL automerge:... n’est pas mémorable, et la conversation où on l’avait reçue peut s’être ensevelie. Si l’entrée du past-list est l’unique chemin de retour, sa suppression accidentelle coupe ce chemin. Le × demande donc confirmation, à l’image du retrait d’un votant.

@testcase
def test_forget_scrutin_needs_confirmation(page):
    """Le × du past-list demande confirmation ; refuser laisse l'entrée."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="Premier")
    page.wait_for_timeout(300)
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    page.locator(".past-scrutins").wait_for(state="visible")

    page.once("dialog", lambda d: d.dismiss())
    page.locator(".past-scrutins li").nth(0).get_by_role("button", name="Retirer").click()
    assert page.locator(".past-scrutins li").count() == 1, "entrée retirée malgré dismiss"
    print("  PASS: forget scrutin needs confirmation")

@testcase
def test_past_scrutins_list(page):
    """L'empty-state liste les scrutins déjà visités, du plus récent
    au plus ancien ; un clic re-attache, et le × retire l'entrée."""
    clear_state(page)

    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="Premier")
    page.wait_for_timeout(300)  # flush IDB

    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

    create_scrutin(page, candidates=["X", "Y"], voters=["Bob"], title="Deuxieme")
    page.wait_for_timeout(300)

    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

    page.locator(".past-scrutins").wait_for(state="visible")
    items = page.locator(".past-scrutins li").all_inner_texts()
    assert any("Premier"  in t for t in items), f"Premier missing: {items}"
    assert any("Deuxieme" in t for t in items), f"Deuxieme missing: {items}"
    assert "Deuxieme" in items[0], f"order should be most-recent-first: {items}"

    # Le × retire la ligne courante (Deuxieme) sans casser Premier.
    page.once("dialog", lambda d: d.accept())
    page.locator(".past-scrutins li").nth(0).get_by_role("button", name="Retirer").click()
    page.wait_for_function(
        "() => document.querySelectorAll('.past-scrutins li').length === 1")
    assert "Premier"  in page.locator(".past-scrutins").inner_text()
    assert "Deuxieme" not in page.locator(".past-scrutins").inner_text()

    page.get_by_role("link", name="Premier").click()
    page.locator(".identity-screen").wait_for(state="visible", timeout=5000)
    assert "Alice" in page.locator(".identity-screen").inner_text()

    print("  PASS: past scrutins list")

<div class="past-scrutins" v-show="!scrutin && !loadingDoc && pastScrutins.length" v-cloak>
  <h2>Scrutins précédents</h2>
  <ul>
    <template v-for="s in pastScrutins" :key="s.url">
      <li>
        <a :href="'?doc=' + s.url" v-text="s.title || 'Sans titre'"></a>
        <button type="button" class="forget"
                @click="forgetScrutin(s.url)" aria-label="Retirer">&times;</button>
      </li>
    </template>
  </ul>
</div>

.past-scrutins{padding:0 20px 20px}
.past-scrutins h2{font-size:.95rem;margin:8px 0;color:var(--muted);font-weight:600;text-align:center}
.past-scrutins ul{list-style:none;padding:0;margin:0 auto;max-width:340px}
.past-scrutins li{display:flex;gap:6px;margin-bottom:6px}
.past-scrutins li a{flex:1;color:var(--fg);text-decoration:none;display:block;padding:10px 14px;background:var(--card);border-radius:8px}
.past-scrutins li button.forget{flex-shrink:0;padding:0 14px;background:var(--card);border:0;border-radius:8px;color:var(--muted);font-size:1.2rem;line-height:1;cursor:pointer}

recordScrutin est appelé dans attach (cf. Store petite-vue) : chaque doc qu’on attache, qu’il vienne d’une création locale, d’un lien partagé ou d’une ré-attache depuis cette même liste, passe par là.

const PAST_KEY = 'condorcet.past-scrutins';

function loadPastScrutins(){
    try {
        const raw = localStorage.getItem(PAST_KEY);
        const arr = raw ? JSON.parse(raw) : [];
        return arr.sort((a, b) => b.at - a.at);
    } catch(e){ return []; }
}

function savePastScrutins(arr){
    try { localStorage.setItem(PAST_KEY, JSON.stringify(arr)); } catch(e) {}
}

Object.assign(voteStore, {
    pastScrutins: [],

    recordScrutin(url, title){
        const arr = (this.pastScrutins || []).filter(s => s.url !== url);
        arr.push({ url, title: title || '', at: Date.now() });
        arr.sort((a, b) => b.at - a.at);
        this.pastScrutins = arr;
        savePastScrutins(arr);
    },

    forgetScrutin(url){
        if(!confirm("Retirer ce scrutin de la liste ? Le doc reste dans IndexedDB.")) return;
        const arr = (this.pastScrutins || []).filter(s => s.url !== url);
        this.pastScrutins = arr;
        savePastScrutins(arr);
    },
});

Créer un scrutin

Pour voter Condorcet il faut au minimum : un titre pour savoir de quoi on parle, au moins deux candidats (pas de duel possible avec un seul), au moins un votant, et un mode. Le mode conditionne tout le flux : partagé = un téléphone qui tourne, per-device = chacun vote depuis chez soi. À la validation on crée un doc Automerge — son URL automerge:... portée dans la query string est ce qu’on partage aux amis pour les inviter : le scrutin est immédiatement partageable.

@testcase
def test_create_scrutin(page):
    """Un scrutin valide active l'écran de vote et publie une URL Automerge."""
    clear_state(page)
    create_scrutin(page, candidates=["Le Parrain", "Inception"],
                   voters=["Alice", "Bob"], title="Quel film ce soir ?")
    assert page.locator(".identity-screen").is_visible()
    assert "doc=automerge:" in page.url
    print("  PASS: create scrutin")

On ouvre la modale par une action explicite pour garder l’empty-state épuré tant qu’il n’y a rien à faire.

openCreate(){
    this.restoreFormDraft();
    this.ui.createOpen = true;
    pushOverlay('create');
},

La modale regroupe les quatre champs — titre, candidats, votants, mode — plus une ligne d’actions (Annuler / Créer). Le minimum de deux candidats et d’un votant est imposé par la méthode : Condorcet repose sur des duels, un seul candidat n’a personne à battre, et sans roster on ne sait pas quand tout le monde a voté.

<div class="modal" id="createModal" :class="{open: ui.createOpen}" v-cloak>
  <div class="modal-panel" @input="saveFormDraft()" @change="saveFormDraft()">
    <h2>Nouveau scrutin</h2>

<label>Titre
  <input type="text" id="createTitle" v-model="ui.form.title"
         placeholder="Quel film ce soir&nbsp;?">
</label>

<fieldset>
  <legend>Candidats</legend>
  <template v-for="(c, i) in ui.form.candidates" :key="'c'+i">
    <div class="row candidate-row">
      <span class="candidate-thumb-wrap" v-if="ui.form.candidateImages[i]">
        <img class="candidate-thumb" :src="ui.form.candidateImages[i]" alt="">
        <button type="button" class="candidate-thumb-remove"
                @click="removeCandidateImage(i)" aria-label="Retirer la photo">&times;</button>
      </span>
      <input type="text" :value="c" @input="ui.form.candidates[i] = $event.target.value"
             @paste="handleCandidatePaste(i, $event)"
             @keydown.tab="onCandidateTab(i, $event)"
             :class="{'is-duplicate': c.trim() && duplicateCandidates.includes(c.trim())}"
             :placeholder="'Candidat ' + (i+1)">
      <label class="candidate-pic" :title="ui.form.candidateImages[i] ? 'Changer la photo' : 'Ajouter une photo'">
        &#x1F4F7;
        <input type="file" accept="image/*" capture="environment"
               @change="setCandidateImage(i, $event.target.files[0])">
      </label>
      <button type="button" @click="ui.form.candidates.splice(i, 1); ui.form.candidateImages.splice(i, 1)"
              v-show="ui.form.candidates.length > 2" aria-label="Retirer">&times;</button>
    </div>
  </template>
  <button type="button" class="add-row"
          @click="ui.form.candidates.push(''); ui.form.candidateImages.push('')">+ Candidat</button>
  <p class="error create-error" v-show="candidatesError" v-text="candidatesError"></p>
</fieldset>

<fieldset>
  <legend>Votants</legend>
  <template v-for="(v, i) in ui.form.voters" :key="'v'+i">
    <div class="row">
      <input type="text" :value="v" @input="ui.form.voters[i] = $event.target.value"
             @keydown.tab="onVoterTab(i, $event)"
             :class="{'is-duplicate': v.trim() && duplicateVoters.includes(v.trim())}"
             :placeholder="'Votant ' + (i+1)">
      <button type="button" @click="ui.form.voters.splice(i, 1)"
              v-show="ui.form.voters.length > 1" aria-label="Retirer">&times;</button>
    </div>
  </template>
  <button type="button" class="add-row" @click="ui.form.voters.push('')">+ Votant</button>
  <p class="error create-error" v-show="votersError" v-text="votersError"></p>
</fieldset>

<fieldset>
  <legend>Mode</legend>
  <label><input type="radio" name="mode" value="shared-device" v-model="ui.form.mode">
    Appareil partagé</label>
  <label><input type="radio" name="mode" value="per-device" v-model="ui.form.mode">
    Un appareil par personne</label>
</fieldset>

Le bouton Créer reste désactivé tant que le formulaire est invalide — pas de soumission en aveugle.

    <div class="modal-actions">
      <button class="secondary" @click="cancelCreate()">Annuler</button>
      <button class="primary" id="btnConfirmCreate"
              :disabled="!canCreate"
              @click="createScrutin()">Créer</button>
    </div>
  </div>
</div>

Le mode partagé est proposé par défaut — c’est le cas le plus courant, un téléphone qui tourne à l’apéro. Les autres champs partent vides, avec deux lignes candidat (minimum Condorcet) et une ligne votant.

function initialForm(){
    return {
        title: '',
        candidates: ['', ''],
        candidateImages: ['', ''],
        voters: [''],
        mode: 'shared-device',
    };
}

Object.assign(voteStore.ui, {
    createOpen: false,
    form: initialForm(),
});

À la validation le doc Automerge est créé, son URL reflétée dans la query string, et on s’y attache tout de suite.

async createScrutin(){
    const repo = await ensureRepo(this);
    const f = this.ui.form;
    const candidateImages = {};
    const candidates = [];
    f.candidates.forEach((raw, i) => {
        const name = raw.trim();
        if(!name) return;
        candidates.push(name);
        if(f.candidateImages[i]) candidateImages[name] = f.candidateImages[i];
    });
    const voters = f.voters.map(s => s.trim()).filter(Boolean);
    const handle = repo.create({
        title: f.title.trim(),
        candidates,
        candidateImages,
        voters,
        ballots: [],
        drafts: {},
        closed: false,
        mode: f.mode,
        method: 'condorcet-random-smith',
        createdAt: Date.now(),
    });
    history.replaceState(null, '', '?doc=' + handle.url);
    this.clearFormDraft();
    this.ui.createOpen = false;
    this.attach(handle);
},

Object.assign(voteStore, {
    openCreate(){
        this.restoreFormDraft();
        this.ui.createOpen = true;
        pushOverlay('create');
    },
    async createScrutin(){
        const repo = await ensureRepo(this);
        const f = this.ui.form;
        const candidateImages = {};
        const candidates = [];
        f.candidates.forEach((raw, i) => {
            const name = raw.trim();
            if(!name) return;
            candidates.push(name);
            if(f.candidateImages[i]) candidateImages[name] = f.candidateImages[i];
        });
        const voters = f.voters.map(s => s.trim()).filter(Boolean);
        const handle = repo.create({
            title: f.title.trim(),
            candidates,
            candidateImages,
            voters,
            ballots: [],
            drafts: {},
            closed: false,
            mode: f.mode,
            method: 'condorcet-random-smith',
            createdAt: Date.now(),
        });
        history.replaceState(null, '', '?doc=' + handle.url);
        this.clearFormDraft();
        this.ui.createOpen = false;
        this.attach(handle);
    },
});

Deux candidats portant le même nom produiraient deux lignes identiques dans la matrice pairwise ; deux votants homonymes permettraient de voter deux fois sous le même nom, ou créeraient une collision sur la clé voter des bulletins. Dans les deux cas le dépouillement serait incohérent. On bloque donc la création dès qu’une liste contient un doublon, avec un message affiché sous le champ concerné pour que l’utilisateur identifie directement la liste à corriger.

@testcase
def test_create_rejects_duplicates(page):
    """Doublons dans candidats ou votants : bouton Créer désactivé +
    message d'erreur visible.  Le scrutin ne peut pas être validé."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("a")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("b")
    page.click(f"{v_field} button.add-row")
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.locator(f"{v_field} input").nth(1).fill("Alice")
    assert page.locator("#btnConfirmCreate").is_disabled()
    assert "Doublons dans les votants" in page.locator(f"{v_field} .error").inner_text()
    page.locator(f"{v_field} input").nth(1).fill("Bob")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("a")
    assert page.locator("#btnConfirmCreate").is_disabled()
    assert "Doublons dans les candidats" in page.locator(f"{c_field} .error").inner_text()
    print("  PASS: create rejects duplicates")

Le message d’erreur dit qu’il y a un doublon mais pas lequel. Avec une longue liste l’utilisateur doit alors la relire entièrement pour retrouver les deux entrées identiques. Pour lui éviter ce travail, les champs concernés s’allument en rouge — toutes les occurrences du doublon, pas seulement la dernière saisie, pour qu’on voie les paires d’un coup d’œil.

@testcase
def test_create_marks_duplicate_fields(page):
    """Quand deux candidats portent le même nom, leurs deux champs
    portent la classe =is-duplicate=. Idem pour les votants. Corriger
    la collision la retire des deux."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    c_inputs = page.locator(f"{c_field} input[type=text]")
    v_inputs = page.locator(f"{v_field} input")

    c_inputs.nth(0).fill("rouge")
    c_inputs.nth(1).fill("rouge")
    assert "is-duplicate" in (c_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" in (c_inputs.nth(1).get_attribute("class") or "")

    page.click(f"{v_field} button.add-row")
    v_inputs.nth(0).fill("Alice")
    v_inputs.nth(1).fill("Alice")
    assert "is-duplicate" in (v_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" in (v_inputs.nth(1).get_attribute("class") or "")

    c_inputs.nth(1).fill("bleu")
    assert "is-duplicate" not in (c_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" not in (c_inputs.nth(1).get_attribute("class") or "")
    print("  PASS: create marks duplicate fields")

La validité du formulaire se rejoue à chaque frappe, et le bouton Créer comme le message d’erreur suivent automatiquement — pas de “Submit” qui ne fait rien. Under the hood, ces deux valeurs sont posées via defineProperty plutôt que Object.assign (qui ne copie pas les accesseurs d’un getter).

function _findDuplicates(items){
    const counts = {};
    items.forEach(s => {
        const t = s.trim();
        if(!t) return;
        counts[t] = (counts[t] || 0) + 1;
    });
    return Object.keys(counts).filter(k => counts[k] > 1);
}

Object.defineProperty(voteStore, 'duplicateCandidates', {
    enumerable: true, configurable: true,
    get(){ return _findDuplicates(this.ui.form.candidates); },
});

Object.defineProperty(voteStore, 'duplicateVoters', {
    enumerable: true, configurable: true,
    get(){ return _findDuplicates(this.ui.form.voters); },
});

Object.defineProperty(voteStore, 'candidatesError', {
    enumerable: true, configurable: true,
    get(){ return this.duplicateCandidates.length > 0 ? 'Doublons dans les candidats.' : ''; },
});

Object.defineProperty(voteStore, 'votersError', {
    enumerable: true, configurable: true,
    get(){ return this.duplicateVoters.length > 0 ? 'Doublons dans les votants.' : ''; },
});

Object.defineProperty(voteStore, 'canCreate', {
    enumerable: true, configurable: true,
    get(){
        const f = this.ui.form;
        const cs = f.candidates.map(s => s.trim()).filter(Boolean);
        const vs = f.voters.map(s => s.trim()).filter(Boolean);
        return f.title.trim() && cs.length >= 2 && vs.length >= 1
            && !this.candidatesError && !this.votersError;
    },
});

Un rechargement accidentel en pleine saisie vide le formulaire ; pour éviter que l’organisateur ressaisisse titre, candidats et votants depuis zéro, le brouillon est persisté dans localStorage sous la clé condorcet.form-draft. Au prochain chargement sa seule présence rouvre la modale automatiquement — repasser par le bouton Créer un scrutin n’a pas de sens quand des entrées attendent déjà l’organisateur, ce bouton n’est utile que sur un état vraiment vierge.

@testcase
def test_form_draft_persists_reload(page):
    """Au reload avec un brouillon non vide, la modale s'ouvre seule
    et les champs sont restaurés tels quels."""
    clear_state(page)
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "Mon scrutin")
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bob")
    page.locator(f"{v_field} input").nth(0).fill("Carol")
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator("#createModal.open").wait_for(state="visible", timeout=3000)
    assert page.input_value("#createTitle") == "Mon scrutin", "titre perdu après reload"
    assert page.locator(f"{c_field} input[type=text]").nth(0).input_value() == "Alice", "Alice perdu"
    assert page.locator(f"{c_field} input[type=text]").nth(1).input_value() == "Bob", "Bob perdu"
    print("  PASS: form draft persists reload")

Le brouillon est effacé dans deux scénarios symétriques : à la création du scrutin (le travail est terminé) et au clic sur Annuler (le travail est rejeté). Sans la branche Annuler, l’organisateur qui change d’avis serait coincé : son brouillon trainerait, la modale se rouvrirait à chaque reload, et l’empty state lui resterait inaccessible.

Annuler est une intention déjà explicite, mais le brouillon peut contenir dix candidats et leurs photos. Sur un brouillon non vide on demande donc confirmation ; sur un brouillon vide la question serait du bruit, donc on l’évite.

@testcase
def test_cancel_create_needs_confirmation(page):
    """Annuler avec brouillon non vide demande confirmation ; refuser
    laisse la modale et le brouillon en place."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "Brouillon")
    page.get_by_placeholder("Candidat 1").fill("Alice")

    page.once("dialog", lambda d: d.dismiss())
    page.get_by_role("button", name="Annuler").click()
    assert page.locator("#createModal.open").is_visible(), "modale fermée malgré dismiss"
    assert page.input_value("#createTitle") == "Brouillon"
    print("  PASS: cancel create needs confirmation")

@testcase
def test_cancel_create_empty_no_confirmation(page):
    """Annuler avec brouillon vide passe sans confirmation."""
    clear_state(page)
    page.click("#btnOpenCreate")
    # Pas de dialog handler : si confirm() était appelé, Playwright
    # dismiss par défaut → la modale resterait ouverte. On vérifie
    # qu'elle se ferme bien.
    page.get_by_role("button", name="Annuler").click()
    assert not page.locator("#createModal.open").is_visible()
    print("  PASS: cancel create empty no confirmation")

@testcase
def test_form_draft_cleared_on_create(page):
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "Brouillon")
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    page.click("#btnOpenCreate")
    assert page.input_value("#createTitle") == "", "brouillon non effacé après création"
    print("  PASS: form draft cleared on create")

@testcase
def test_cancel_clears_draft(page):
    """Annuler la modale supprime le brouillon : l'empty state
    réapparaît et la modale ne s'ouvre plus toute seule au reload."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "Brouillon")
    page.get_by_placeholder("Candidat 1").fill("Alice")
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Annuler").click()
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    assert not page.locator("#createModal.open").is_visible()
    page.click("#btnOpenCreate")
    assert page.input_value("#createTitle") == ""
    assert page.get_by_placeholder("Candidat 1").input_value() == ""
    print("  PASS: cancel clears draft")

const DRAFT_KEY = 'condorcet.form-draft';

function hasNonEmptyDraft(){
    try {
        const raw = localStorage.getItem(DRAFT_KEY);
        if(!raw) return false;
        const d = JSON.parse(raw);
        if(d.title && d.title.trim()) return true;
        if((d.candidates || []).some(c => c && c.trim())) return true;
        if((d.voters || []).some(v => v && v.trim())) return true;
        return false;
    } catch(e){ return false; }
}

Object.assign(voteStore, {
    saveFormDraft(){
        try {
            localStorage.setItem(DRAFT_KEY, JSON.stringify(this.ui.form));
        } catch(e) {}
    },

    restoreFormDraft(){
        try {
            const raw = localStorage.getItem(DRAFT_KEY);
            if(raw) Object.assign(this.ui.form, JSON.parse(raw));
        } catch(e) {}
    },

    clearFormDraft(){
        localStorage.removeItem(DRAFT_KEY);
    },

    cancelCreate(){
        if(hasNonEmptyDraft() && !confirm("Annuler ? Le brouillon (titre, candidats, votants) sera effacé.")) return;
        this._doCancelCreate();
        if(history.state && history.state.overlay === 'create') history.back();
    },

    _doCancelCreate(){
        this.clearFormDraft();
        Object.assign(this.ui.form, initialForm());
        this.ui.importUrl = '';
        this.ui.importError = '';
        this.ui.createOpen = false;
    },
});

Recevoir un lien automerge:... ne devrait pas obliger à retaper tous les candidats et votants pour relancer un scrutin similaire. Un champ Importer depuis URL dans la modale charge le doc visé en arrière-plan et préremplit titre, candidats (avec photos), votants et mode — l’organisateur ajuste ensuite ce qui change avant de cliquer Créer. On accepte aussi un lien complet contenant ?doc=automerge:... pour épargner à l’utilisateur d’extraire le préfixe.

@testcase
def test_import_from_url(page):
    """Coller une URL automerge dans le champ d'import préremplit
    le form de création."""
    clear_state(page)
    create_scrutin(page, candidates=["Pizza", "Sushi"], voters=["Alice"],
                   title="Apéro", mode="per-device")
    doc_url = page.url.split("?doc=")[1]
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    # Retour à l'empty state pour partir d'un form vierge ; le doc
    # reste disponible en IndexedDB.
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    _role_button(page, "Créer un scrutin").click()
    page.get_by_label("Importer depuis URL").fill(doc_url)
    _role_button(page, "Importer", exact=True).click()
    page.wait_for_function(
        "() => document.querySelector('#createTitle')?.value === 'Apéro'",
        timeout=3000)
    assert page.get_by_placeholder("Candidat 1").input_value() == "Pizza"
    assert page.get_by_placeholder("Candidat 2").input_value() == "Sushi"
    assert page.get_by_placeholder("Votant 1").input_value() == "Alice"
    assert page.get_by_label("Un appareil par personne").is_checked()
    print("  PASS: import from url")

Le doc cible peut déjà être en IndexedDB (déjà visité ou copié par ce navigateur), ou arriver via le sync server. repo.find() renvoie un handle qui résout dans les deux cas ; on attend 'ready' ou 'unavailable' pour ne pas bloquer l’UI quand le doc n’existe nulle part. Une fois résolu, on projette son contenu dans ui.form comme le ferait Refaire (cf. Dépouiller) et on déclenche saveFormDraft pour que le brouillon survive un reload.

<fieldset>
  <legend>Importer depuis URL</legend>
  <div class="row">
    <input type="text" v-model="ui.importUrl"
           placeholder="automerge:… ou un lien complet"
           aria-label="Importer depuis URL">
    <button type="button" class="secondary"
            @click="importFromUrl()"
            :disabled="!ui.importUrl.trim()">Importer</button>
  </div>
  <p class="error import-error" v-show="ui.importError" v-text="ui.importError"></p>
</fieldset>

Object.assign(voteStore.ui, {
    importUrl: '',
    importError: '',
});

Object.assign(voteStore, {
    async importFromUrl(){
        this.ui.importError = '';
        const raw = (this.ui.importUrl || '').trim();
        if(!raw) return;
        let url = raw;
        if(/^https?:\/\//.test(raw)){
            try {
                url = new URL(raw).searchParams.get('doc') || '';
            } catch(e){ this.ui.importError = 'URL invalide'; return; }
        }
        const am = await loadAutomerge();
        if(!am.isValidAutomergeUrl(url)){
            this.ui.importError = 'Pas un lien automerge:';
            return;
        }
        try {
            const repo = await ensureRepo(this);
            const handle = repo.find(url);
            await handle.whenReady(['ready', 'unavailable']);
            const doc = handle.docSync();
            if(!doc){ this.ui.importError = 'Doc introuvable'; return; }
            const cands = [...(doc.candidates || [])];
            Object.assign(this.ui.form, {
                title: doc.title || '',
                candidates: cands.length ? cands : ['', ''],
                candidateImages: cands.map(c =>
                    (doc.candidateImages && doc.candidateImages[c]) || ''),
                voters: doc.voters && doc.voters.length ? [...doc.voters] : [''],
                mode: doc.mode || 'shared-device',
            });
            this.saveFormDraft();
            this.ui.importUrl = '';
        } catch(e){
            this.ui.importError = 'Erreur : ' + e.message;
        }
    },
});

Sur desktop, Tab est le geste réflexe pour passer au champ suivant. Mais l’ordre DOM par défaut accroche les boutons 📷 et × avant d’arriver au candidat suivant, et il n’y a pas d’équivalent côté votants : on tabule sur le × puis le candidat d’à côté, plutôt que sur le votant suivant. Tab dans un champ candidat ou votant doit donc sauter directement au suivant, et créer une ligne de plus si on est au bout d’une liste non vide — saisir des noms à la file redevient un seul geste par nom. Shift+Tab reste libre de remonter en arrière sans nous accrocher.

@testcase
def test_tab_advances_form_fields(page):
    """Tab dans un candidat focus le suivant ; sur le dernier rempli,
    une ligne est créée et focusée. Idem côté votants."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.get_by_placeholder("Candidat 1").fill("Alice")
    page.get_by_placeholder("Candidat 1").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Candidat 2'")
    page.locator(":focus").fill("Bob")
    page.locator(":focus").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Candidat 3'")
    page.get_by_placeholder("Votant 1").fill("Carol")
    page.get_by_placeholder("Votant 1").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Votant 2'")
    print("  PASS: tab advances form fields")

function focusFormField(prefix, idx){
    document.querySelector(
        `#createModal input[placeholder="${prefix} ${idx + 1}"]`)?.focus();
}

Object.assign(voteStore, {
    onCandidateTab(i, e){
        if(e.shiftKey) return;
        const arr = this.ui.form.candidates;
        const isLast = i >= arr.length - 1;
        if(isLast && arr[i].trim() === '') return;
        e.preventDefault();
        if(isLast){
            arr.push('');
            this.ui.form.candidateImages.push('');
        }
        requestAnimationFrame(() => focusFormField('Candidat', i + 1));
    },
    onVoterTab(i, e){
        if(e.shiftKey) return;
        const arr = this.ui.form.voters;
        const isLast = i >= arr.length - 1;
        if(isLast && arr[i].trim() === '') return;
        e.preventDefault();
        if(isLast) arr.push('');
        requestAnimationFrame(() => focusFormField('Votant', i + 1));
    },
});

À l’apéro, quelqu’un recharge l’app par accident — on ne peut pas perdre les bulletins déjà soumis, ils représentent de vrais votes. Le doc Automerge est persisté dans IndexedDB ; l’URL automerge:... dans la query string suffit au rechargement pour retrouver exactement le même état.

@testcase
def test_persistence_reload(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    take_phone(page)
    page.evaluate("window.testForceRanking(['B', 'A'])")
    page.click("#btnSubmit")
    url_before = page.url
    # La sauvegarde IndexedDB est debounced (100 ms) ; on attend qu'elle soit flushée.
    page.wait_for_timeout(300)
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    assert page.url == url_before, f"url changed: {url_before}{page.url}"
    ballots = page.evaluate("JSON.stringify(window.__voteStore.scrutin.ballots)")
    assert "Alice" in ballots and "B" in ballots and "A" in ballots, f"ballots lost: {ballots}"
    # Après reload on retombe sur l'identity-screen, prêt pour le votant suivant.
    page.locator(".identity-screen").wait_for(state="visible")
    identity_text = page.locator(".identity-screen").inner_text()
    assert "Alice" in identity_text and "a voté" in identity_text
    print("  PASS: persistence reload")

La persistance doit aussi préserver la réactivité : après un reload, un nouveau bulletin doit mettre à jour le compteur n/m et faire apparaître la close-bar dès que allVoted bascule — sans décalage ni rechargement manuel. C’est l’invariant qui justifie de conserver dans le store un snapshot JSON plat du doc plutôt qu’un Proxy Automerge direct (cf. Store petite-vue pour la règle complète).

@testcase
def test_reload_reactivity(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    take_phone(page)
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    counter_before = page.locator(".identity-screen .counter").inner_text()
    assert "1" in counter_before and "2" in counter_before, f"pre-reload: {counter_before}"
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator(".identity-screen").wait_for(state="visible")
    take_phone(page)
    page.evaluate("window.testForceRanking(['B', 'A'])")
    page.click("#btnSubmit")
    page.locator("#btnClose").wait_for(state="visible", timeout=5000)
    counter_after = page.locator(".identity-screen .counter").inner_text()
    assert "2" in counter_after, f"counter didn't update after reload: {counter_after}"
    print("  PASS: reload reactivity")

Titre toujours en bandeau

Le titre du scrutin se cale en haut, sous la rangée d’en-tête (← Menu, build-tag, indicateur de sync), et y reste sur tous les écrans : on a toujours sous les yeux ce sur quoi on vote.

<div class="scrutin-title" v-show="scrutin" v-cloak>
  <h1 v-text="scrutin.title"></h1>
</div>

.scrutin-title{padding:0 20px 4px 20px;text-align:center}
.scrutin-title h1{margin:0;font-size:1.05rem;color:var(--muted);font-weight:600}

Partager le scrutin (lien et QR)

L’URL ?doc=automerge:... est le sésame pour rejoindre le scrutin. Mais sur mobile, personne ne copie une URL depuis la barre d’adresse. Tous les écrans hors-bulletin — pass, identification, attente, tally — exposent donc deux raccourcis : partage natif ou clipboard pour envoyer par message, QR pour partage en face-à-face.

Les deux boutons vivent dans une .share-bar épinglée, plus un toast pour le feedback quand on passe par le clipboard.

<div class="share-bar" v-show="scrutin && stage !== 'ballot'" v-cloak>
  <button class="secondary" id="btnShare" @click="shareScrutin()" title="Partager">
    🔗 Partager
  </button>
  <button class="secondary" id="btnQR" @click="openQR()" title="QR code">
    ▦ QR
  </button>
  <span class="share-toast" v-show="ui.shareToast" v-text="ui.shareToast"></span>
</div>

.share-bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:12px 20px;flex-wrap:wrap}
.share-bar button{padding:8px 14px;font-size:.9rem}
.share-toast{color:var(--ok);font-size:.85rem;padding:4px 8px}
.qr-modal .modal-panel{text-align:center}
#qrHolder{margin:16px auto;display:inline-block;background:#fff;padding:10px;border-radius:8px}
#qrHolder svg{display:block;max-width:260px;height:auto}
.qr-url{font-family:monospace;font-size:.75rem;color:var(--muted);word-break:break-all;margin:12px 0}

Sur mobile, navigator.share déclenche la sheet native — l’utilisateur choisit lui-même où envoyer (SMS, Signal, email…). C’est de loin l’expérience la plus fluide, et c’est le chemin par défaut quand l’API est disponible.

@testcase
def test_share_native(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.evaluate("""() => {
      window.__shareCalls = [];
      navigator.share = async (data) => { window.__shareCalls.push(data); };
    }""")
    page.click("#btnShare")
    page.wait_for_timeout(100)
    calls = page.evaluate("window.__shareCalls")
    assert len(calls) == 1, f"expected 1 share call, got {calls}"
    assert "doc=automerge:" in calls[0].get("url", ""), f"bad share url: {calls}"
    print("  PASS: share native")

Quand navigator.share est absent (desktop, vieux navigateur), copier dans le clipboard est le minimum acceptable. Sans retour visuel, l’utilisateur ne saurait pas si l’action a fonctionné et risquerait de réessayer inutilement — d’où le toast.

@testcase
def test_share_clipboard(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.evaluate("""() => {
      delete navigator.share;
      window.__clip = [];
      navigator.clipboard.writeText = async (t) => { window.__clip.push(t); };
    }""")
    page.click("#btnShare")
    page.wait_for_timeout(200)
    clip = page.evaluate("window.__clip")
    assert len(clip) == 1, f"expected 1 clipboard call, got {clip}"
    assert "doc=automerge:" in clip[0], f"bad clipboard url: {clip}"
    # Toast visible
    assert page.locator(".share-toast").is_visible()
    print("  PASS: share clipboard fallback")

shareScrutin essaie d’abord l’API native, puis retombe sur le clipboard en affichant un toast (“Lien copié” ou “Copie impossible” selon le résultat), qui s’efface après 2 s.

Object.assign(voteStore.ui, {
    shareToast: '',
});

Object.assign(voteStore, {
    currentShareUrl(){
        return location.origin + location.pathname + location.search;
    },

    async shareScrutin(){
        const url = this.currentShareUrl();
        const title = (this.scrutin && this.scrutin.title) || 'Scrutin';
        if(navigator.share){
            try { await navigator.share({ title, url }); return; }
            catch(e){ /* annulé ou non supporté → fallback clipboard */ }
        }
        try {
            await navigator.clipboard.writeText(url);
            this.ui.shareToast = 'Lien copié';
            setTimeout(() => { this.ui.shareToast = ''; }, 2000);
        } catch(e){
            this.ui.shareToast = 'Copie impossible';
            setTimeout(() => { this.ui.shareToast = ''; }, 2000);
        }
    },
});

Pour un partage en face-à-face autour d’une table, taper l’URL est exclu. Un QR scannable avec l’appareil photo est la friction minimale ; il est généré côté client, sans appel serveur.

<div class="modal qr-modal" :class="{open: ui.qrOpen}" v-cloak @click.self="closeQR()">
  <div class="modal-panel">
    <h2>Rejoindre ce scrutin</h2>
    <div id="qrHolder" v-html="ui.qrSvg"></div>
    <p class="qr-url" v-text="ui.shareUrl"></p>
    <button class="secondary" @click="closeQR()">Fermer</button>
  </div>
</div>

@testcase
def test_qr(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.click("#btnQR")
    page.locator(".qr-modal.open").wait_for()
    page.locator(".qr-modal #qrHolder svg").wait_for()
    url_shown = page.locator(".qr-url").inner_text()
    assert "doc=automerge:" in url_shown, f"URL not shown: {url_shown}"
    assert url_shown.startswith("http"), f"Bad URL: {url_shown}"
    print("  PASS: qr code")

openQR génère le SVG avec qrcode-generator et l’affiche dans la modale, avec l’URL en texte monospace sous le code pour les cas où le scan échoue.

Object.assign(voteStore.ui, {
    qrOpen: false,
    qrSvg: '',
    shareUrl: '',
});

Object.assign(voteStore, {
    openQR(){
        const url = this.currentShareUrl();
        const qr = window.qrcode(0, 'M');
        qr.addData(url);
        qr.make();
        this.ui.qrSvg = qr.createSvgTag({ cellSize: 6, margin: 2 });
        this.ui.shareUrl = url;
        this.ui.qrOpen = true;
        pushOverlay('qr');
    },

    closeQR(){
        this._doCloseQR();
        if(history.state && history.state.overlay === 'qr') history.back();
    },

    _doCloseQR(){
        this.ui.qrOpen = false;
    },
});

Photos de candidats

Voter avec des enfants qui ne lisent pas encore, ou avec un contexte multilingue, rend le texte pur insuffisant : “Kebab” et “Sushi” sont des étiquettes opaques pour eux. Permettre à chaque candidat une photo change la donne — l’enfant reconnaît la frite ou la pizza à l’image, pointe son préféré, glisse les tuiles du bulletin. Le nom reste comme clé technique (matrice, anti-collision, fallback si l’image ne charge pas) ; l’image devient l’affordance principale.

Sur mobile, la photo qu’on veut pour un candidat n’existe pas encore : on désigne le plat sur la table, on appuie 📷, et la capture vit dans la modale (l’attribut capture“environment”= ouvre directement la caméra arrière, sans sélecteur de fichier intermédiaire). Sur desktop, c’est l’inverse : la photo a presque toujours déjà été créée — un screenshot fraîchement pris, une image copiée d’un site, une vignette tirée d’un message — et elle vit dans le presse-papiers. Forcer un détour par Enregistrer sous… puis Sélectionner un fichier… brise le Ctrl+V que tout utilisateur desktop a dans les doigts. Les deux entrées coexistent donc sur la même ligne, et la miniature qui apparaît à gauche du nom est la même quel qu’ait été le geste. Rien n’est envoyé à un serveur : l’image est compressée côté client et stockée en dataURL dans le doc Automerge, qui se propage via la sync comme le reste du scrutin.

Une photo brute d’iPhone fait 3-5 MB, inenvoyable à propager dans un CRDT à chaque tick. On re-encode donc systématiquement en JPEG qualité 0.75 avec une dimension max de 512 px via <canvas>, ce qui ramène une photo typique à 40-80 KB — négligeable dans le doc. L’utilisateur qui prend une très grosse photo ne s’en rend pas compte : la compression est transparente, et le résultat reste lisible sur le bulletin de vote.

Pour que l’enfant puisse zoomer sur une vignette 64×64, on autorise le pinch-zoom en surchargeant le <meta viewport> — voir Bases visuelles.

@testcase
def test_candidate_image(page):
    """Une image uploadée dans le formulaire apparaît sur le bulletin
    de vote et sur la carte vainqueur. Le nom reste la clé, l'image
    décore."""
    import base64, pathlib, tempfile
    clear_state(page)
    # 1×1 pixel rouge en PNG : plus petite image valide possible.
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)

    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bleu")
    # Upload sur le premier candidat (via input[type=file] caché).
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    # La miniature doit apparaître dans la modale après compression.
    page.wait_for_function(
        "() => document.querySelector('#createModal .candidate-thumb')"
        " && document.querySelector('#createModal .candidate-thumb').offsetParent !== null",
        timeout=3000)
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.click("#btnConfirmCreate")
    page.wait_for_selector(".identity-screen")

    # Au bulletin, l'image du candidat Rouge apparaît.
    take_phone(page)
    page.wait_for_function(
        "() => document.querySelector('.reorder-list .reorder-image[src^=\"data:\"]')",
        timeout=3000)
    page.evaluate("window.testForceRanking(['Rouge', 'Bleu'])")
    page.click("#btnSubmit")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "Rouge"
    # Carte vainqueur : image visible.
    img = page.locator(".winner-image")
    assert img.is_visible(), "image du vainqueur absente"
    src = img.get_attribute("src")
    assert src and src.startswith("data:image/jpeg"), \
        f"attendu dataURL JPEG compressée, got {src[:40] if src else None}"
    print("  PASS: candidate image")

Tester le Ctrl+V passe par un détour : Playwright ne donne pas d’accès au presse-papiers système sans permission, alors on fabrique un ClipboardEvent à la main avec une File synthétique. La construction est un peu ingrate, mais c’est précisément le geste utilisateur qu’elle reproduit — et le succès se mesure au même endroit qu’avec un upload : la miniature qui doit apparaître.

@testcase
def test_candidate_image_paste(page):
    """Coller (Ctrl+V) une image dans le champ texte du candidat
    déclenche la même compression que l'upload : une miniature
    apparaît à gauche du nom."""
    clear_state(page)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    # 1×1 PNG rouge synthétisé en File, déposé dans un DataTransfer,
    # puis dispatché comme ClipboardEvent('paste') sur l'input texte.
    png_b64 = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
               "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    page.evaluate("""(b64) => {
        const bin = atob(b64);
        const arr = new Uint8Array(bin.length);
        for(let i=0; i<bin.length; i++) arr[i] = bin.charCodeAt(i);
        const file = new File([arr], 'pasted.png', {type: 'image/png'});
        const dt = new DataTransfer();
        dt.items.add(file);
        const input = document.querySelectorAll(
            '#createModal .candidate-row input[type=text]')[0];
        input.dispatchEvent(new ClipboardEvent('paste', {
            clipboardData: dt, bubbles: true, cancelable: true}));
    }""", png_b64)
    page.wait_for_selector(
        "#createModal .candidate-thumb[src^='data:']", timeout=3000)
    print("  PASS: candidate image paste")

Une image prise puis perdue au reload gâche la soirée : elle doit survivre au F5 comme le reste du scrutin, via la persistance du doc Automerge en IndexedDB.

@testcase
def test_candidate_image_persists_reload(page):
    """L'image est stockée en dataURL dans le doc Automerge (persisté
    IndexedDB), donc un F5 la restitue à l'identique."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bleu")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    page.click("#btnConfirmCreate")
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-image[src^='data:']", timeout=3000)
    src_before = page.locator(".reorder-image").first.get_attribute("src")
    assert src_before and src_before.startswith("data:image/jpeg")
    # Reload : Alice n'a pas voté, on retombe sur l'identity-screen.
    page.wait_for_timeout(300)  # flush IndexedDB debouncé
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-image[src^='data:']", timeout=3000)
    src_after = page.locator(".reorder-image").first.get_attribute("src")
    assert src_before == src_after, \
        "dataURL modifiée par le reload : before={!r} after={!r}".format(
            src_before[:40], src_after[:40])
    print("  PASS: candidate image persists reload")

Tous les candidats n’auront pas forcément une photo. Quand certains en ont et d’autres non, le <img> du candidat sans photo reste caché (v-show=false) — pas de carré vide dans le bulletin.

@testcase
def test_candidate_image_mixed(page):
    """Deux candidats, un seul avec image : le bulletin affiche une
    seule =.reorder-image= rendue (l'autre est v-show=false)."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("AvecImage")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("SansImage")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    page.click("#btnConfirmCreate")
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-list")
    visible_count = page.evaluate(
        "() => Array.from(document.querySelectorAll('.reorder-image'))"
        ".filter(el => el.offsetParent !== null).length")
    assert visible_count == 1, \
        f"attendu 1 image visible (AvecImage), got {visible_count}"
    # Et l'image visible est bien sur le bon candidat.
    src = page.locator(
        ".reorder-item[data-candidate='AvecImage'] .reorder-image"
    ).get_attribute("src")
    assert src and src.startswith("data:image/jpeg"), \
        f"AvecImage sans dataURL JPEG : {src[:40] if src else None}"
    print("  PASS: candidate image mixed")

Les noms et les images vivent dans deux tableaux parallèles indexés pareillement. Retirer une ligne candidat doit donc aussi retirer son image au même index, sinon chaque image suivante se décalerait sur un autre candidat.

@testcase
def test_candidate_image_row_removal(page):
    """Retirer la ligne C1 (via ×) laisse l'image sur C2 --- le splice
    sur candidates et sur candidateImages reste en phase."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    page.click("#btnOpenCreate")
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    # Active le bouton × (visible uniquement quand > 2 candidats).
    page.click(f"{c_field} button.add-row")
    page.locator(f"{c_field} input[type=text]").nth(0).fill("C1")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("C2")
    page.locator(f"{c_field} input[type=text]").nth(2).fill("C3")
    # Image sur C2 (index 1), puis retire C1 (index 0).
    page.locator(f"{c_field} input[type=file]").nth(1).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    page.locator(f"{c_field} .candidate-row").nth(0) \
        .locator("button[aria-label='Retirer']").click()
    # Vérifie : 2 candidats, C2 en tête avec sa miniature.
    rows = page.locator(f"{c_field} .candidate-row")
    assert rows.count() == 2
    names = rows.locator("input[type=text]").evaluate_all("els => els.map(e => e.value)")
    assert names == ["C2", "C3"], f"ordre après × : {names}"
    first_has_thumb = rows.nth(0).locator(".candidate-thumb").count() == 1
    second_has_thumb = rows.nth(1).locator(".candidate-thumb").count() == 1
    assert first_has_thumb and not second_has_thumb, \
        f"miniatures après × : first={first_has_thumb}, second={second_has_thumb}"
    # Pousse jusqu'au bulletin pour s'assurer que le doc reflète aussi.
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.click("#btnConfirmCreate")
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-list")
    c2_image = page.locator(
        ".reorder-item[data-candidate='C2'] .reorder-image"
    ).count() == 1
    c3_image = page.locator(
        ".reorder-item[data-candidate='C3'] .reorder-image"
    ).count() == 1
    assert c2_image and not c3_image, \
        f"bulletin après ×+create : C2={c2_image}, C3={c3_image}"
    print("  PASS: candidate image row removal")

Une mauvaise photo se pose vite : un screenshot raté, le mauvais plat, une compression partie de travers. Il faut pouvoir la retirer sans pour autant supprimer le candidat lui-même — son nom est peut-être déjà bon. Un × en surimpression sur la miniature efface la dataURL et laisse le nom intact, prêt à recevoir une autre photo ou à partir tel quel.

@testcase
def test_candidate_image_can_be_removed(page):
    """× sur la miniature efface la photo et laisse le nom intact."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "p.png"
    tmp.write_bytes(png)
    page.click("#btnOpenCreate")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']",
                           timeout=3000)
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Retirer la photo").first.click()
    page.wait_for_selector(f"{c_field} .candidate-row .candidate-thumb",
                           state="hidden", timeout=3000)
    assert page.locator(f"{c_field} input[type=text]").nth(0).input_value() == "Alice"
    print("  PASS: candidate image can be removed")

La photo est de la donnée réelle : elle a coûté un cadrage, un appui sur l’obturateur, parfois une compression côté browser. La détruire par accident veut dire recommencer. Le × demande donc confirmation, et refuser laisse la miniature en place.

@testcase
def test_candidate_image_remove_needs_confirmation(page):
    """× sur la miniature demande confirmation ; refuser laisse la photo."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "p.png"
    tmp.write_bytes(png)
    page.click("#btnOpenCreate")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)

    page.once("dialog", lambda d: d.dismiss())
    page.get_by_role("button", name="Retirer la photo").first.click()
    assert page.locator("#createModal .candidate-thumb[src^='data:']").count() == 1, \
        "photo effacée malgré dismiss"
    print("  PASS: candidate image remove needs confirmation")

La dataURL compressée se pose directement dans ui.form.candidateImages[i], au même niveau que les autres champs du formulaire — pas de store séparé pour les images. La compression vit dans setCandidateImage que 📷 et Ctrl+V partagent : si demain la dimension max ou la qualité JPEG change, il n’y a qu’un endroit où le faire. La suppression écrit dans le même slot, pour rester en phase avec le brouillon de formulaire.

const IMAGE_MAX_DIM = 512;
const IMAGE_QUALITY = 0.75;

function compressImageToDataUrl(file){
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onerror = () => reject(reader.error);
        reader.onload = () => {
            const img = new Image();
            img.onerror = () => reject(new Error('image load failed'));
            img.onload = () => {
                const scale = Math.min(1, IMAGE_MAX_DIM / Math.max(img.width, img.height));
                const w = Math.round(img.width * scale);
                const h = Math.round(img.height * scale);
                const canvas = document.createElement('canvas');
                canvas.width = w; canvas.height = h;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, w, h);
                resolve(canvas.toDataURL('image/jpeg', IMAGE_QUALITY));
            };
            img.src = reader.result;
        };
        reader.readAsDataURL(file);
    });
}

Object.assign(voteStore, {
    async setCandidateImage(i, file){
        if(!file) return;
        try {
            const dataUrl = await compressImageToDataUrl(file);
            this.ui.form.candidateImages[i] = dataUrl;
            this.saveFormDraft();
        } catch(e){
            console.error('compression image candidat', e);
        }
    },

    handleCandidatePaste(i, ev){
        const items = ev.clipboardData && ev.clipboardData.items;
        if(!items) return;
        for(const item of items){
            if(item.kind === 'file' && item.type.startsWith('image/')){
                ev.preventDefault();
                const file = item.getAsFile();
                if(file) this.setCandidateImage(i, file);
                return;
            }
        }
    },

    removeCandidateImage(i){
        if(!confirm('Retirer cette photo ?')) return;
        this.ui.form.candidateImages[i] = '';
        this.saveFormDraft();
    },
});

Trois tailles d’image pour trois contextes : miniature (36 px) dans la modale de création pour valider le choix photo, médium (64 px) dans le bulletin pour reconnaître un candidat d’un coup d’œil, grande (140 px) sur la carte vainqueur pour célébrer le résultat.

Les boîtes sont carrées mais les photos ne le sont pas toujours : on utilise object-fit: contain et non cover, pour qu’une photo en portrait ou en paysage soit affichée entière (avec un peu de fond autour) plutôt que tronquée sur un visage ou un détail saillant.

.candidate-row{align-items:center}
.candidate-thumb-wrap{position:relative;display:inline-flex;flex-shrink:0}
.candidate-thumb{width:36px;height:36px;object-fit:contain;border-radius:6px;border:1px solid #333;flex-shrink:0}
.candidate-thumb-remove{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:9px;background:var(--danger);color:#fff;font-size:.75rem;line-height:1;padding:0;display:flex;align-items:center;justify-content:center}
.candidate-pic{display:inline-flex;align-items:center;justify-content:center;background:var(--card);color:var(--fg);padding:8px 10px;border-radius:6px;cursor:pointer;font-size:1.1rem;line-height:1}
.candidate-pic input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.reorder-image{width:64px;height:64px;object-fit:contain;border-radius:8px;margin:0 8px;flex-shrink:0}
.winner-image{display:block;width:140px;height:140px;object-fit:contain;border-radius:12px;margin:10px auto 6px auto;border:2px solid var(--ok)}

Ajouter un votant en cours de route

Prévoir la liste exacte des votants au moment de créer le scrutin suppose de tout savoir à l’avance — à l’apéro, c’est rarement le cas. Un retardataire arrive, un invité surprise se joint. Si l’ajout n’est possible que depuis l’écran de création, on est bloqués. La barre d’ajout est donc accessible sur tous les écrans hors-bulletin, y compris après clôture.

Les noms des votants servent de clé dans les bulletins : un doublon permettrait de voter deux fois sous le même nom et bloquerait le dépouillement, donc l’ajout d’un homonyme est rejeté avec un message visible. Ajouter quelqu’un après la clôture doit aussi avoir un sens cohérent : un tally calculé sans ce votant n’est plus valide, donc le scrutin se ré-ouvre automatiquement et la tally-screen disparaît le temps que le nouveau venu vote — le dépouillement se relancera à la prochaine clôture manuelle.

Un cas particulier mérite attention en mode per-device : si on est sur l’écran d’identification (pas encore identifié sur cet appareil), l’ajout déclenche aussi l’auto-identification — la personne qui s’ajoute bascule directement sur son propre bulletin. Sur les autres écrans on ajoute juste le nom — le nouveau venu cliquera son nom dans l’identity-screen depuis son propre appareil (la liste est mise à jour via Automerge/sync).

<div class="add-voter-bar"
     v-show="scrutin && stage !== 'ballot'"
     v-cloak>
  <input type="text" v-model="ui.newVoter"
         placeholder="Ajouter un votant…"
         @keyup.enter="addVoter()">
  <button class="secondary" id="btnAddVoter"
          @click="addVoter()" :disabled="!ui.newVoter.trim()">
    + Ajouter
  </button>
  <p class="error" v-show="ui.addVoterError" v-text="ui.addVoterError"></p>
</div>

.add-voter-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #333;margin-top:12px}
.add-voter-bar input{flex:1;min-width:140px}
.add-voter-bar button{padding:10px 14px;font-size:.9rem}
.add-voter-bar .error{color:var(--danger);font-size:.85rem;margin:6px 0 0 0;flex-basis:100%;text-align:center}

Un retardataire qui arrive après les premiers bulletins doit pouvoir s’inscrire sans interrompre le scrutin ni invalider les votes déjà enregistrés.

@testcase
def test_add_voter_mid_vote(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    # Alice a voté. On ajoute Charlie au roster.
    page.locator(".add-voter-bar input").fill("Charlie")
    page.click("#btnAddVoter")
    assert page.evaluate("window.__voteStore.scrutin.voters") == ["Alice", "Bob", "Charlie"]
    # Charlie peut être picked directement (l'ordre n'est pas imposé).
    pick_identity(page, "Charlie")
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    pick_identity(page, "Bob")
    page.evaluate("window.testForceRanking(['B', 'A'])")
    page.click("#btnSubmit")
    page.locator("#btnClose").wait_for(state="visible")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    assert page.evaluate("window.__voteStore.scrutin.ballots.length") == 3
    print("  PASS: add voter mid vote")

Un résultat affiché sans que tout le monde ait voté n’est pas valide. Quand quelqu’un s’ajoute après la clôture, le scrutin se ré-ouvre — le tally affiché ne compte plus, il sera recalculé une fois que le nouveau venu aura voté.

@testcase
def test_add_voter_after_tally(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    for r in [["A", "B"], ["B", "A"]]:
        take_phone(page)
        page.evaluate(f"window.testForceRanking({r})")
        page.click("#btnSubmit")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    # On ajoute Charlie depuis la tally-screen : le tally disparaît,
    # on retombe sur l'identity-screen et Charlie peut voter.
    page.locator(".add-voter-bar input").fill("Charlie")
    page.click("#btnAddVoter")
    page.locator(".winner-name").wait_for(state="hidden")
    page.locator(".identity-screen").wait_for(state="visible")
    assert page.evaluate("window.__voteStore.scrutin.closed") is False
    pick_identity(page, "Charlie")
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    page.locator("#btnClose").wait_for(state="visible")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    assert page.evaluate("window.__voteStore.scrutin.ballots.length") == 3
    print("  PASS: add voter after tally")

Un doublon dans le roster permettrait de voter deux fois sous le même nom — ou créerait une collision sur la clé voter dans les bulletins. On rejette avec un message visible pour que l’utilisateur comprenne pourquoi.

@testcase
def test_add_voter_duplicate(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    page.locator(".add-voter-bar input").fill("Alice")
    page.click("#btnAddVoter")
    assert page.locator(".add-voter-bar .error").is_visible()
    assert page.evaluate("window.__voteStore.scrutin.voters") == ["Alice", "Bob"]
    print("  PASS: add voter duplicate rejected")

En mode per-device, quelqu’un qui s’ajoute depuis l’identity-screen a déjà choisi son nom : lui imposer un deuxième clic sur l’écran d’identification serait une friction gratuite. On le bascule directement sur son bulletin.

@testcase
def test_add_voter_per_device_self(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], mode="per-device")
    # Bob n'est pas dans la liste, il s'ajoute depuis l'identity-screen.
    page.locator(".add-voter-bar input").fill("Bob")
    page.click("#btnAddVoter")
    # Il est directement basculé sur le bulletin.
    page.locator(".ballot-screen").wait_for(state="visible")
    assert "Bob" in page.locator(".ballot-screen h2").inner_text()
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    page.locator(".waiting-screen").wait_for(state="visible")
    assert "Bob" in page.locator(".waiting-screen h2").inner_text()
    assert page.locator(".scrutin-title").inner_text() == "T"
    assert page.evaluate("window.__voteStore.scrutin.voters") == ["Alice", "Bob"]
    print("  PASS: add voter per-device self")

Object.assign(voteStore.ui, {
    newVoter: '',
    addVoterError: '',
});

Object.assign(voteStore, {
    addVoter(){
        this.ui.addVoterError = '';
        const name = (this.ui.newVoter || '').trim();
        if(!name){ this.ui.addVoterError = 'Nom requis'; return; }
        if(this.scrutin.voters.includes(name)){
            this.ui.addVoterError = 'Ce nom est déjà dans la liste';
            return;
        }
        const wasIdentifying = this.scrutin.mode === 'per-device' && this.stage === 'identify';
        this.change(d => {
            d.voters.push(name);
            if(d.closed) d.closed = false;
        });
        this.tally = null;
        this.ui.newVoter = '';
        if(wasIdentifying) this.chooseIdentity(name);
    },
});

Retirer un votant en cours de route

Miroir de l’ajout : un invité s’en va avant la clôture, ou un nom a été typé en doublon orthographique (Antoine et Anthoine). Sans retrait, le fantôme bloque allVoted — le scrutin ne peut clore tant que ce nom n’a pas voté. Un × discret à droite de chaque nom dans l’identity-screen retire le votant ; son bulletin et son brouillon éventuels partent avec lui, parce qu’un bulletin attaché à un votant absent reste compté dans les duels et fausse le tally.

@testcase
def test_remove_voter_takes_ballot(page):
    """Retirer un votant qui a déjà voté retire aussi son bulletin ;
    les bulletins des autres restent intacts."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    page.evaluate("window.testForceRanking(['A', 'B'])")
    submit_ballot(page)
    pick_identity(page, "Bob")
    page.evaluate("window.testForceRanking(['B', 'A'])")
    submit_ballot(page)

    page.locator(".identity-screen").wait_for(state="visible")
    assert ballot_count(page) == (2, 2)

    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    page.once("dialog", lambda d: d.accept())
    bob_li.get_by_role("button", name="Retirer ce votant").click()
    bob_li.wait_for(state="hidden")

    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" in identity_text and "Bob" not in identity_text
    # Compteur passé à 1/1 : seul le bulletin d'Alice subsiste,
    # celui de Bob est parti avec lui.
    assert ballot_count(page) == (1, 1)
    print("  PASS: remove voter takes ballot")

Comme pour l’ajout, retirer après clôture re-ouvre le scrutin : le tally affiché incluait ce votant dans ses duels, il n’est plus valide tant qu’on ne dépouille pas à nouveau.

@testcase
def test_remove_voter_after_tally(page):
    """Retirer un votant après dépouillement re-ouvre le scrutin et
    masque la winner-card."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    for r in [["A", "B"], ["B", "A"]]:
        take_phone(page)
        page.evaluate(f"window.testForceRanking({r})")
        submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")

    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    page.once("dialog", lambda d: d.accept())
    bob_li.get_by_role("button", name="Retirer ce votant").click()

    # winner-card disparue + identity-screen visible : le scrutin a
    # bien été ré-ouvert (la winner-card n'est rendue que si closed).
    page.locator(".winner-name").wait_for(state="hidden")
    page.locator(".identity-screen").wait_for(state="visible")
    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" in identity_text and "Bob" not in identity_text
    print("  PASS: remove voter after tally")

Un scrutin sans votant n’a personne à départager. On bloque le retrait du dernier nom en faisant disparaître son × tant que la roster n’a qu’un seul votant ; l’utilisateur peut toujours en ajouter un autre puis retirer celui-ci.

@testcase
def test_remove_voter_floor(page):
    """Les × sont rendus tant qu'il reste plusieurs votants ; ils
    disparaissent dès qu'on atteint un seul nom dans la roster."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    removers = page.get_by_role("button", name="Retirer ce votant")
    assert removers.count() == 2

    alice_li = page.locator(".identity-list li").filter(has_text="Alice")
    page.once("dialog", lambda d: d.accept())
    alice_li.get_by_role("button", name="Retirer ce votant").click()
    alice_li.wait_for(state="hidden")

    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" not in identity_text and "Bob" in identity_text
    assert removers.count() == 0
    print("  PASS: remove voter floor enforced")

Le × est petit, et le pouce qui parcourt la liste peut l’accrocher par accident. Comme la suppression est irréversible — bulletin et brouillon partent avec le nom —, on demande confirmation avant d’agir : refuser laisse le votant intact, sans toucher à l’état du scrutin.

@testcase
def test_remove_voter_needs_confirmation(page):
    """Le × demande confirmation ; refuser laisse le votant en place
    et son bulletin intact."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Bob")
    page.evaluate("window.testForceRanking(['B', 'A'])")
    submit_ballot(page)

    page.once("dialog", lambda d: d.dismiss())
    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    bob_li.get_by_role("button", name="Retirer ce votant").click()

    identity_text = page.locator(".identity-list").inner_text()
    assert "Bob" in identity_text, f"Bob removed despite dismiss: {identity_text}"
    assert ballot_count(page) == (1, 2), f"ballot_count={ballot_count(page)}"
    print("  PASS: remove voter needs confirmation")

Comme addVoter, l’écriture tient en une seule transaction Automerge ; le tally local est invalidé pour qu’il se recalcule à la prochaine clôture.

Object.assign(voteStore, {
    removeVoter(name){
        if(!confirm(`Retirer ${name} ? Son bulletin et son brouillon seront effacés.`)) return;
        const vIdx = this.scrutin.voters.indexOf(name);
        const bIdx = this.scrutin.ballots.findIndex(b => b.voter === name);
        this.change(d => {
            d.voters.splice(vIdx, 1);
            if(bIdx >= 0) d.ballots.splice(bIdx, 1);
            if(d.drafts && d.drafts[name]) delete d.drafts[name];
            if(d.closed) d.closed = false;
        });
        this.tally = null;
    },
});

Départager : les duels de Condorcet

Pour plus de deux options, le vote à la majorité simple crée des paradoxes. Le scrutin de Condorcet compare chaque duel entre candidats. En cas de cycle (pas de vainqueur net), on départage par un tirage au sort reproductible dans le Smith set.

Dépouiller

Un résultat sorti de nulle part ne convainc pas. Pour que le groupe fasse confiance au vainqueur, chacun doit pouvoir vérifier : “est-ce que mon candidat a vraiment perdu ?” La matrice des duels rend ça lisible : pour chaque paire (A, B), on voit combien de votants ont préféré A à B et combien l’inverse. C’est aussi l’occasion d’expliquer Condorcet en le pratiquant — la méthode devient intuitive sur un vrai vote.

Chaque bulletin traduit un classement en préférences pairwise : pour tout couple (A, B), si A est classé avant B dans le bulletin, on incrémente pairwise[A][B]. Agrégés sur tous les bulletins, ces compteurs forment la matrice. A bat B en duel quand pairwise[A][B] > pairwise[B][A].

Si un candidat bat chacun des autres en tête-à-tête, la majorité le préfère à n’importe quel challenger — c’est le vainqueur de Condorcet, la solution indiscutable. L’app doit l’afficher tel quel et signaler qu’il est strict (pas de tirage nécessaire), pour que les votants comprennent pourquoi ce candidat a gagné et pas seulement qui.

@testcase
def test_condorcet_winner(page):
    # V1: A>B>C, V2: A>C>B, V3: B>A>C
    # A bat B (2-1), A bat C (3-0), B bat C (2-1) → A est vainqueur de Condorcet
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["V1", "V2", "V3"])
    for ranking in [["A", "B", "C"], ["A", "C", "B"], ["B", "A", "C"]]:
        take_phone(page)
        page.evaluate(f"window.testForceRanking({ranking})")
        page.click("#btnSubmit")
    page.click("#btnClose")
    assert page.locator(".winner-name").inner_text() == "A"
    assert "strict" in page.locator(".tally-kind").inner_text()
    assert page.locator(".scrutin-title").inner_text() == "T"
    print("  PASS: Condorcet winner")

Deux temps, deux fonctions : d’abord bâtir la matrice des duels, puis la scanner pour y repérer un éventuel gagnant strict.

function pairwiseMatrix(candidates, ballots){
    const m = {};
    for(const a of candidates){
        m[a] = {};
        for(const b of candidates) if(b !== a) m[a][b] = 0;
    }
    for(const ballot of ballots){
        const pos = {};
        ballot.ranking.forEach((c, i) => { pos[c] = i; });
        for(let i = 0; i < candidates.length; i++){
            for(let j = i+1; j < candidates.length; j++){
                const a = candidates[i], b = candidates[j];
                if(a in pos && b in pos){
                    if(pos[a] < pos[b]) m[a][b]++;
                    else m[b][a]++;
                }
            }
        }
    }
    return m;
}

function condorcetWinner(candidates, m){
    for(const c of candidates){
        const beatsAll = candidates.every(o => o === c || m[c][o] > m[o][c]);
        if(beatsAll) return c;
    }
    return null;
}

Mais ce vainqueur peut ne pas exister. Le paradoxe de Condorcet, classique à trois votants :

votant classement
V1 A > B > C
V2 B > C > A
V3 C > A > B

Duels : A bat B (V1, V3 préfèrent A, V2 préfère B → 2-1), B bat C (2-1), C bat A (2-1). Chaque candidat en bat un et est battu par un autre : la préférence majoritaire est intransitive. Il n’y a pas de “meilleur” au sens de Condorcet — il faut donc une règle de secours. Face à ce cycle, l’app doit basculer dans le chemin de tirage aléatoire dans le Smith set, et signaler “Smith” dans l’explication pour que les votants comprennent qu’on n’est plus dans le cas strict.

@testcase
def test_random_smith(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["V1", "V2", "V3"])
    for ranking in [["A", "B", "C"], ["B", "C", "A"], ["C", "A", "B"]]:
        take_phone(page)
        page.evaluate(f"window.testForceRanking({ranking})")
        page.click("#btnSubmit")
    page.click("#btnClose")
    assert page.locator(".winner-name").inner_text() in ("A", "B", "C")
    assert "Smith" in page.locator(".tally-kind").inner_text()
    print("  PASS: random-smith tiebreak")

La règle de secours consulte le Smith set : le plus petit ensemble non-vide S tel que chaque membre de S bat (en duel) chaque candidat extérieur à S. Autrement dit, la “short-list” des candidats qui restent sérieusement en course après qu’on a exclu ceux qu’une majorité préfère à un autre finaliste. Propriétés utiles :

  • Quand un vainqueur de Condorcet existe, le Smith set vaut {vainqueur}. Le cas général englobe donc le cas strict.
  • Dans le paradoxe ci-dessus, le Smith set vaut {A, B, C} : chacun des trois en bat un autre, aucun n’est dominé par un extérieur (il n’y en a pas).
  • Si on ajoute un quatrième candidat D battu par les trois du cycle, le Smith set reste {A, B, C} et D en est exclu — D n’a pas voix au tirage.

Pour construire le Smith set on part de l’ensemble complet et on éjecte itérativement tout membre qu’un candidat extérieur bat en duel, jusqu’à stabilisation. Ce qui reste est par définition le plus petit ensemble dominant — et D, battu par tout le monde, en est naturellement exclu.

function smithSet(candidates, m){
    let smith = new Set(candidates);
    let changed = true;
    while(changed){
        changed = false;
        for(const c of Array.from(smith)){
            const outsiders = candidates.filter(o => !smith.has(o));
            const beatenByOutside = outsiders.some(o => m[o][c] > m[c][o]);
            if(beatenByOutside){ smith.delete(c); changed = true; }
        }
    }
    return Array.from(smith).sort();
}

Dans le Smith set, il faut encore départager. Math.random() suffirait techniquement, mais alors le dépouillement ne serait pas vérifiable : personne ne pourrait, après coup, confirmer que l’app n’a pas triché. On veut un tirage reproductible — à bulletins identiques, même vainqueur, recalculable par n’importe qui en JS ou en Python — tout en gardant le résultat aléatoire vis-à-vis des votants. Les quatre premiers octets de la graine sont affichés au dépouillement pour qu’on puisse citer “le tirage abcd1234”.

Under the hood, cette reproductibilité prend la forme d’un hash SHA-256 calculé sur les bulletins triés par nom de votant (ordre stable indépendant de l’ordre de soumission) ; les quatre premiers octets fournissent le germe entier d’un LCG simple (mulberry32), suffisant pour un tirage unique et vérifiable.

async function seedFromBallots(ballots){
    const normalised = ballots
          .map(b => ({voter: b.voter, ranking: b.ranking}))
          .sort((a, b) => a.voter.localeCompare(b.voter));
    const payload = JSON.stringify(normalised);
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
    return new Uint8Array(buf);
}

function mulberry32(seed){
    let a = seed >>> 0;
    return function(){
        a |= 0; a = a + 0x6D2B79F5 | 0;
        let t = Math.imul(a ^ a >>> 15, 1 | a);
        t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
}

computeTally orchestre les deux chemins : d’abord tester le vainqueur strict (s’il existe, on ignore Smith et tirage) ; sinon calculer le Smith set, dériver la graine, et tirer un index avec mulberry32. Le résultat retourné est directement consommable côté template, sans logique supplémentaire.

async function computeTally(scrutin){
    const m = pairwiseMatrix(scrutin.candidates, scrutin.ballots);
    const w = condorcetWinner(scrutin.candidates, m);
    if(w) return { kind: 'condorcet', winner: w, pairwise: m };
    const smith = smithSet(scrutin.candidates, m);
    const seedBytes = await seedFromBallots(scrutin.ballots);
    const seedInt = new DataView(seedBytes.buffer).getUint32(0);
    const rng = mulberry32(seedInt);
    const pick = smith[Math.floor(rng() * smith.length)];
    const seedShort = Array.from(seedBytes.slice(0, 4))
          .map(b => b.toString(16).padStart(2, '0')).join('');
    return { kind: 'random-smith', winner: pick, smith, pairwise: m, seedShort };
}

Le titre du scrutin reste en bandeau au-dessus du résultat.

<div id="tallyScreen" v-show="scrutin && scrutin.closed" v-cloak>
  <h2>Résultat</h2>
  <template v-if="tally">
    <div>
      <p class="tally-kind" v-text="tallyExplanation"></p>
      <div class="winner-card">
        <div class="winner-label">Vainqueur</div>
        <img class="winner-image" v-if="scrutin.candidateImages && scrutin.candidateImages[tally.winner]"
             :src="scrutin.candidateImages[tally.winner]" alt="">
        <div class="winner-name" v-text="tally.winner"></div>
      </div>

      <h3>Matrice des duels</h3>
      <table class="pairwise">
        <thead>
          <tr>
            <th></th>
            <template v-for="c in scrutin.candidates" :key="'h'+c">
              <th v-text="c"></th>
            </template>
          </tr>
        </thead>
        <tbody>
          <template v-for="a in scrutin.candidates" :key="'r'+a">
            <tr>
              <th v-text="a"></th>
              <template v-for="b in scrutin.candidates" :key="'c'+a+b">
                <td :class="{self: a === b, wins: a !== b && tally.pairwise[a][b] > tally.pairwise[b][a], 'pair-current': pairCurrent(a, b)}"
                    v-text="a === b ? '—' : tally.pairwise[a][b]"></td>
              </template>
            </tr>
          </template>
        </tbody>
      </table>

      <div class="tally-animate">
        <button class="secondary" id="btnAnimate"
                v-show="!ui.anim.active" @click="animateStart()">
          &#x25B6; Animer
        </button>
        <div class="anim-transport" v-show="ui.anim.active">
          <button class="icon" id="btnAnimPrev"
                  :disabled="ui.anim.step === 0"
                  title="Précédent" @click="animatePrev()">&#x23EE;</button>
          <button class="icon" id="btnAnimToggle"
                  :title="ui.anim.timer ? 'Pause' : 'Lecture'"
                  @click="animateToggle()">
            <span v-show="!ui.anim.timer">&#x25B6;</span>
            <span v-show="ui.anim.timer">&#x23F8;</span>
          </button>
          <button class="icon" id="btnAnimNext"
                  :disabled="ui.anim.step >= ui.anim.total - 1"
                  title="Suivant" @click="animateNext()">&#x23ED;</button>
          <span class="anim-counter" v-text="animCounterText"></span>
          <button class="icon anim-speed" id="btnAnimSpeed"
                  title="Vitesse" @click="animateCycleSpeed()"
                  v-text="ui.anim.speed + 'x'"></button>
          <button class="icon" id="btnAnimStop"
                  title="Fermer" @click="animateStop()">&#x2715;</button>
        </div>
        <p class="anim-legend" v-show="ui.anim.legend" v-text="ui.anim.legend"></p>
        <ul class="anim-breakdown" v-show="ui.anim.active && ui.anim.breakdown.length" v-cloak>
          <template v-for="row in ui.anim.breakdown" :key="row.voter">
            <li :class="{processed: row.processed, current: row.current}">
              <span class="voter" v-text="row.voter"></span>
              <span class="arrow">&#x2192;</span>
              <span class="pref" v-text="row.prefers || '—'"></span>
            </li>
          </template>
        </ul>
      </div>

      <button class="secondary redo-vote" id="btnRedoVote" @click="redoScrutin()">Refaire</button>
      <button class="primary new-vote" id="btnNewVote" @click="newVote()">Nouveau vote</button>

      <div v-if="tally.kind === 'random-smith'" class="smith-note">
        <h3>Aucun vainqueur strict --- Smith set</h3>
        <p>Pas de candidat ne bat tous les autres en duel : il y a un
          cycle. Le Smith set est le plus petit ensemble dominant. On y
          tire au sort avec une graine reproductible calculée sur les
          bulletins clos.</p>
        <ul>
          <template v-for="c in tally.smith" :key="'s'+c">
            <li v-text="c"></li>
          </template>
        </ul>
        <p class="seed">Graine : <code v-text="tally.seedShort"></code></p>
      </div>
    </div>
  </template>
</div>

#tallyScreen{padding:20px}
.tally-kind{color:var(--muted);font-size:.9rem}
.winner-card{background:var(--card);padding:24px;border-radius:12px;text-align:center;margin:16px 0;border:2px solid var(--ok)}
.winner-label{font-size:.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.winner-name{font-size:2rem;font-weight:800;margin-top:6px}
.pairwise{width:100%;border-collapse:collapse;margin:12px 0}
.pairwise th,.pairwise td{padding:6px 10px;text-align:center;border:1px solid #333}
.pairwise td.self{background:#1a1a2a;color:var(--muted)}
.pairwise td.wins{color:var(--ok);font-weight:700}
.smith-note{background:var(--card);padding:12px 16px;border-radius:8px;margin-top:16px}
.smith-note .seed{color:var(--muted);font-size:.8rem;margin-top:8px}
.redo-vote{display:block;width:100%;margin-top:24px}
.new-vote{display:block;width:100%;margin-top:8px}

Le clic sur “Dépouiller” ne doit pas laisser de moment de flottement : dès la clôture, l’UI affiche le vainqueur dans une carte et explique en une phrase pourquoi il a gagné. Les votants doivent comprendre, pas seulement voir — la confiance dans le résultat tient à ce que chacun puisse se dire “oui, mon candidat a vraiment été battu en duel”, d’où la matrice des duels affichée juste en dessous.

Un résultat n’a de valeur que s’il reste affiché après un reload ou l’ouverture d’une copie partagée du doc : le groupe cite le vainqueur, un retardataire ouvre le lien, quelqu’un revient plus tard sur la page — la winner-card doit apparaître dès que le scrutin clos est rehydraté, sans action manuelle.

@testcase
def test_tally_persists_after_reload(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    take_phone(page)
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "A"
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator(".winner-name").wait_for(state="visible", timeout=5000)
    assert page.locator(".winner-name").inner_text() == "A"
    print("  PASS: tally persists after reload")

Une fois le vainqueur connu, le groupe veut souvent revoter — soit sur autre chose, soit sur la même chose. Deux boutons couvrent les deux cas, le doc clos restant dans tous les cas persisté en IndexedDB et accessible par son URL.

Nouveau vote renvoie cet onglet à l’empty state pour repartir d’une page blanche : strip de ?doc…= et rechargement.

@testcase
def test_new_vote_button(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    take_phone(page)
    page.evaluate("window.testForceRanking(['A', 'B'])")
    page.click("#btnSubmit")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    page.click("#btnNewVote")
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    assert "doc=" not in page.url
    print("  PASS: new vote button")

Refaire sert le cas opposé : un votant arrivé en retard, un candidat qu’on a oublié, ou une revanche sur le même groupe. Plutôt que de tout retaper, le bouton recopie le scrutin clos dans le brouillon de formulaire et la modale de création s’ouvre seule au rechargement avec titre, candidats (et leurs photos), votants et mode déjà remplis. L’organisateur n’a qu’à ajuster ce qui change.

@testcase
def test_redo_prefills_form(page):
    """« Refaire » recopie le scrutin clos dans la modale de création :
    titre, candidats, votants, mode pré-remplis."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"],
                   title="Apéro", mode="per-device")
    pick_identity(page, "Alice")
    page.evaluate("window.testForceRanking(['A', 'B'])")
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    page.get_by_role("button", name="Refaire", exact=True).click()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_label("Titre").input_value() == "Apéro"
    assert page.get_by_placeholder("Candidat 1").input_value() == "A"
    assert page.get_by_placeholder("Candidat 2").input_value() == "B"
    assert page.get_by_placeholder("Votant 1").input_value() == "Alice"
    assert page.get_by_label("Un appareil par personne").is_checked()
    assert "doc=" not in page.url
    print("  PASS: redo prefills form")

Le brouillon réutilise le slot localStorage.condorcet.form-draft déjà géré par la persistance du formulaire : aucune mécanique nouvelle, juste à reprojeter la map candidateImages={nom: data} du doc en l’array parallèle aux candidats que le formulaire attend. Refaire n’est qu’un raccourci pour « recharger avec un brouillon prérempli » : l’auto-ouverture qui suit est exactement la même que pour n’importe quel autre brouillon.

Object.defineProperty(voteStore, 'tallyExplanation', {
    enumerable: true, configurable: true,
    get(){
        if(!this.tally) return '';
        if(this.tally.kind === 'condorcet')
            return 'Vainqueur de Condorcet strict : bat tous les autres en duel.';
        return 'Aucun vainqueur de Condorcet : cycle détecté. Tirage au sort reproductible dans le Smith set.';
    },
});

Object.assign(voteStore, {
    async close(){
        this.change(d => { d.closed = true; });
        this.tally = await computeTally(this.scrutin);
    },

    newVote(){
        location.href = location.pathname;
    },

    redoScrutin(){
        const s = this.scrutin;
        const draft = {
            title: s.title,
            candidates: [...s.candidates],
            candidateImages: s.candidates.map(c =>
                (s.candidateImages && s.candidateImages[c]) || ''),
            voters: [...s.voters],
            mode: s.mode,
        };
        try { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch(e) {}
        location.href = location.pathname;
    },
});

Rejouer le dépouillement

La matrice affiche les scores finaux mais cache le chemin : un novice qui voit “A : 2 / 1 / 0” sur la ligne A ne reconstitue pas seul d’où viennent ces chiffres. Pour que chacun comprenne comment on arrive au tableau — et pas seulement qui a gagné — un bouton Animer rejoue le dépouillement bulletin par bulletin, à la manière d’un lecteur audio.

L’unité de pas est le bulletin à l’intérieur d’un duel. Pour chaque paire (a, b) prise dans l’ordre du scan naturel (i < j), on déroule les bulletins un par un : chaque bulletin incrémente d’une unité le côté qu’il préfère (+1 Kebab si le votant a classé Kebab avant Sushi), la case correspondante de la matrice tique, et un panneau sous la légende affiche tous les bulletins pour cette paire, avec le bulletin courant en surbrillance, les précédents en opacité pleine, les suivants grisés. Le “d’où viennent ces 2 et ces 1” devient explicite : on voit qui vote pour quoi et le compteur grimper.

Au clic sur Animer, la matrice se remplace par sa version partielle (pointillés sur les paires non jouées, compteur partiel sur la paire en cours), on se pose sur le premier bulletin de la première paire en pause — pas d’auto-play qui déroule avant que le votant ait eu le temps de comprendre ce qu’il regarde. Une barre de transport remplace le bouton : ⏮ précédent, ▶/⏸ lecture/pause, ⏭ suivant, un compteur sémantique Duel n/P · Bulletin b/B (P paires, B bulletins), un bouton de vitesse qui cycle entre 1x, 2x et 0.5x, et ✕ pour fermer. Le compteur sémantique évite le calcul mental “4/9 → quelle paire, quel bulletin ?”. À l’utilisateur de lancer la lecture avec ▶ quand il a absorbé le contexte, ou d’avancer à son rythme avec ⏭. Au dernier pas la lecture s’arrête d’elle-même ; un ▶ depuis la fin relance depuis le début, comme rejouer un morceau.

La légende seule ne suffit pas à relier visuellement le duel en cours aux cases concernées de la matrice. Deux flashes orange se succèdent sur les cases miroirs de la paire courante : un gros flash quand on entre dans une nouvelle paire, un plus discret quand seul le compteur grimpe à l’intérieur d’une même paire. L’œil suit le point chaud du regard. Under the hood, le premier flash est un keyframe CSS posé par la classe pair-current ; le second doit être déclenché impérativement via la Web Animations API, parce qu’un keyframe CSS ne rejoue pas sans changement de classe.

@testcase
def test_animate_tally(page):
    """Après dépouillement, Animer pose l'utilisateur sur le premier
    pas en pause (pas d'auto-play), puis les contrôles type lecteur
    audio permettent lecture, pause, next/prev, et fermeture qui
    restaure la matrice."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
    for r in [["A", "B", "C"], ["B", "A", "C"]]:
        take_phone(page)
        page.evaluate(f"window.testForceRanking({r})")
        page.click("#btnSubmit")
    page.click("#btnClose")
    page.locator(".winner-name").wait_for(state="visible")
    cells = lambda: page.locator(".pairwise td").all_inner_texts()
    before = cells()

    # Click Animer : transport visible, premier pas affiché, pas d'auto-play.
    page.click("#btnAnimate")
    page.locator(".anim-transport").wait_for(state="visible")
    assert "…" in cells(), f"attendu placeholders après start : {cells()}"
    assert "Duel" in page.locator(".anim-legend").inner_text()
    # Deux cases miroirs de la paire courante portent la classe pair-current.
    assert page.locator(".pairwise td.pair-current").count() == 2, \
        "attendu 2 cases miroirs mises en évidence"
    # Le panneau de breakdown liste les bulletins (2 votants = 2 lignes)
    # dont exactement une marquée "current".
    assert page.locator(".anim-breakdown li").count() == 2
    assert page.locator(".anim-breakdown li.current").count() == 1
    # Compteur sémantique : "Duel x/3 · Bulletin y/2" (3 paires, 2 votants).
    counter = page.locator(".anim-counter").inner_text()
    assert "Duel" in counter and "Bulletin" in counter \
        and "/3" in counter and "/2" in counter, \
        f"compteur sémantique attendu, got {counter!r}"
    # Pas d'auto-play : le compteur doit rester figé sans action.
    page.wait_for_timeout(1700)
    assert page.locator(".anim-counter").inner_text() == counter, \
        f"auto-play inattendu : compteur a avancé de {counter!r}"

    # Next avance d'un pas, Prev revient.
    page.click("#btnAnimNext")
    assert page.locator(".anim-counter").inner_text() != counter, \
        "Next n'a pas avancé"
    page.click("#btnAnimPrev")
    assert page.locator(".anim-counter").inner_text() == counter, \
        "Prev n'a pas reculé à l'état précédent"

    # Play : toggle démarre le timer, compteur avance après un tick.
    page.click("#btnAnimToggle")
    page.wait_for_timeout(1700)
    playing = page.locator(".anim-counter").inner_text()
    assert playing != counter, "Play n'a pas démarré le timer"
    # Pause : toggle à nouveau, compteur figé.
    page.click("#btnAnimToggle")
    paused = page.locator(".anim-counter").inner_text()
    page.wait_for_timeout(1700)
    assert page.locator(".anim-counter").inner_text() == paused, \
        "Pause n'a pas arrêté le timer"

    # Speed cycle : 1x → 2x → 0.5x → 1x.
    speed = page.locator("#btnAnimSpeed")
    assert speed.inner_text().strip() == "1x", f"défaut : {speed.inner_text()}"
    speed.click()
    assert speed.inner_text().strip() == "2x"
    speed.click()
    assert speed.inner_text().strip() == "0.5x"
    speed.click()
    assert speed.inner_text().strip() == "1x"

    # Stop : matrice restaurée, transport disparaît, pair-current disparaît.
    page.click("#btnAnimStop")
    page.locator(".anim-transport").wait_for(state="hidden")
    assert cells() == before, f"matrice non restaurée après stop : {cells()}"
    assert page.locator(".pairwise td.pair-current").count() == 0, \
        "pair-current persiste après stop"
    print("  PASS: animate tally")

La barre de transport est une rangée d’icônes centrée sous la matrice, avec Play et Pause rendus en un seul bouton à deux états — une zone cliquable unique pour la bascule, comme sur tous les lecteurs.

<div class="tally-animate">
  <button class="secondary" id="btnAnimate"
          v-show="!ui.anim.active" @click="animateStart()">
    &#x25B6; Animer
  </button>
  <div class="anim-transport" v-show="ui.anim.active">
    <button class="icon" id="btnAnimPrev"
            :disabled="ui.anim.step === 0"
            title="Précédent" @click="animatePrev()">&#x23EE;</button>
    <button class="icon" id="btnAnimToggle"
            :title="ui.anim.timer ? 'Pause' : 'Lecture'"
            @click="animateToggle()">
      <span v-show="!ui.anim.timer">&#x25B6;</span>
      <span v-show="ui.anim.timer">&#x23F8;</span>
    </button>
    <button class="icon" id="btnAnimNext"
            :disabled="ui.anim.step >= ui.anim.total - 1"
            title="Suivant" @click="animateNext()">&#x23ED;</button>
    <span class="anim-counter" v-text="animCounterText"></span>
    <button class="icon anim-speed" id="btnAnimSpeed"
            title="Vitesse" @click="animateCycleSpeed()"
            v-text="ui.anim.speed + 'x'"></button>
    <button class="icon" id="btnAnimStop"
            title="Fermer" @click="animateStop()">&#x2715;</button>
  </div>
  <p class="anim-legend" v-show="ui.anim.legend" v-text="ui.anim.legend"></p>
  <ul class="anim-breakdown" v-show="ui.anim.active && ui.anim.breakdown.length" v-cloak>
    <template v-for="row in ui.anim.breakdown" :key="row.voter">
      <li :class="{processed: row.processed, current: row.current}">
        <span class="voter" v-text="row.voter"></span>
        <span class="arrow">&#x2192;</span>
        <span class="pref" v-text="row.prefers || '—'"></span>
      </li>
    </template>
  </ul>
</div>

.tally-animate{text-align:center;margin:12px 0}
.tally-animate > button{padding:8px 14px;font-size:.9rem}
.anim-transport{display:inline-flex;gap:6px;align-items:center;justify-content:center;flex-wrap:wrap;background:var(--card);padding:6px 10px;border-radius:999px}
.anim-transport button.icon{background:transparent;color:var(--fg);padding:6px 10px;font-size:1rem;border-radius:999px}
.anim-transport button.icon:disabled{opacity:.3}
.anim-transport button.anim-speed{font-size:.8rem;font-weight:600;min-width:2.4em;padding:6px 8px}
.anim-counter{color:var(--muted);font-size:.85rem;padding:0 6px}
.anim-legend{color:var(--muted);font-size:.9rem;margin:10px 0 0 0;min-height:1.3em}
@keyframes pair-flash{
    0%{background:rgba(249,168,38,0)}
    40%{background:rgba(249,168,38,.65)}
    100%{background:rgba(249,168,38,.22)}
}
.pairwise td.pair-current{background:rgba(249,168,38,.22);animation:pair-flash .5s ease}
.anim-breakdown{list-style:none;padding:0;margin:8px auto 0 auto;max-width:320px;text-align:left}
.anim-breakdown li{display:flex;gap:8px;align-items:center;padding:4px 10px;border-radius:6px;color:var(--muted);opacity:.4;transition:opacity .3s,background .3s}
.anim-breakdown li.processed{opacity:1;color:var(--fg)}
.anim-breakdown li.current{background:rgba(249,168,38,.18)}
.anim-breakdown .voter{flex:1;font-weight:500}
.anim-breakdown .arrow{color:var(--muted)}
.anim-breakdown .pref{font-weight:700}

Le compteur sémantique “Duel n/P · Bulletin b/B” demande de savoir à tout moment où on en est dans quelle paire. Un seul pas courant couvrant la grille (paire × bulletin) suffit : le compteur y lit tout ce qu’il affiche sans logique côté template.

Object.assign(voteStore.ui, {
    anim: { active: false, step: 0, total: 0, pairs: [], ballots: [],
            full: null, timer: null, legend: '', breakdown: [],
            pairIdx: 0, ballotIdx: 0, speed: 1, _lastPair: -1 },
});

Object.defineProperty(voteStore, 'animCounterText', {
    enumerable: true, configurable: true,
    get(){
        const a = this.ui.anim;
        if(!a.active || !a.pairs.length) return '';
        return `Duel ${a.pairIdx+1}/${a.pairs.length}` +
               ` · Bulletin ${a.ballotIdx+1}/${a.ballots.length}`;
    },
});

Le compteur partiel et le breakdown affichent tous deux “qui préfère qui” sur chaque bulletin : ils doivent s’appuyer sur une seule fonction (_ballotPref) pour éviter que l’animation ne se contredise elle-même en cours de tick.

Object.assign(voteStore, {
    pairCurrent(a, b){
        if(!this.ui.anim.active || a === b) return false;
        const cur = this.ui.anim.pairs[this.ui.anim.pairIdx];
        if(!cur) return false;
        return (a === cur[0] && b === cur[1]) || (a === cur[1] && b === cur[0]);
    },

    _ballotPref(ballot, a, b){
        const pa = ballot.ranking.indexOf(a);
        const pb = ballot.ranking.indexOf(b);
        if(pa === -1 || pb === -1) return null;
        return pa < pb ? a : b;
    },
});

À chaque pas, la matrice affichée est reconstruite plutôt que mutée — une nouvelle référence est la seule chose que petite-vue sait détecter à coup sûr sans tracer les mutations profondes. Au passage, on affiche gratuitement le rendu partiel : paires terminées avec leurs valeurs finales, paire courante avec son compteur actuel, paires futures en pointillés.

Object.assign(voteStore, {
    _animPartial(pairIdx, ballotIdx){
        const cs = this.scrutin.candidates;
        const m = {};
        for(const a of cs){
            m[a] = {};
            for(const b of cs) if(b !== a) m[a][b] = '…';
        }
        for(let k = 0; k < pairIdx; k++){
            const [a, b] = this.ui.anim.pairs[k];
            m[a][b] = this.ui.anim.full[a][b];
            m[b][a] = this.ui.anim.full[b][a];
        }
        const [a, b] = this.ui.anim.pairs[pairIdx];
        let xa = 0, xb = 0;
        for(let i = 0; i <= ballotIdx; i++){
            const pref = this._ballotPref(this.ui.anim.ballots[i], a, b);
            if(pref === a) xa++;
            else if(pref === b) xb++;
        }
        m[a][b] = xa;
        m[b][a] = xb;
        return m;
    },

    _animRender(){
        const B = this.ui.anim.ballots.length;
        const s = this.ui.anim.step;
        const p = Math.floor(s / B);
        const bi = s % B;
        const samePair = this.ui.anim._lastPair === p;
        this.ui.anim._lastPair = p;
        this.ui.anim.pairIdx = p;
        this.ui.anim.ballotIdx = bi;
        this.tally.pairwise = this._animPartial(p, bi);
        const [a, b] = this.ui.anim.pairs[p];
        const ballot = this.ui.anim.ballots[bi];
        this.ui.anim.legend = `Duel ${a} vs ${b} — bulletin de ${ballot.voter}`;
        const breakdown = [];
        for(let i = 0; i < B; i++){
            const bal = this.ui.anim.ballots[i];
            breakdown.push({
                voter: bal.voter,
                prefers: this._ballotPref(bal, a, b),
                processed: i <= bi,
                current: i === bi,
            });
        }
        this.ui.anim.breakdown = breakdown;
        if(samePair) this._animValueFlash();
    },

    _animValueFlash(){
        requestAnimationFrame(() => {
            document.querySelectorAll('.pairwise td.pair-current').forEach(el => {
                el.animate([
                    { backgroundColor: 'rgba(249,168,38,.6)' },
                    { backgroundColor: 'rgba(249,168,38,.22)' },
                ], { duration: 400, easing: 'ease-out' });
            });
        });
    },
});

Le transport se comporte comme un lecteur audio. Un clic manuel (précédent/suivant) pause implicitement : l’utilisateur prend le contrôle, le timer ne doit pas l’écraser au tick suivant. Arrivé au bout, ▶ rejoue depuis le début — rejouer le morceau plutôt que pousser dans le vide. Le cycle de vitesse (1x, 2x, 0.5x) s’applique immédiatement en cours de lecture — pas besoin de relancer pour que le nouveau tempo prenne effet.

Object.assign(voteStore, {
    animateStart(){
        if(!this.tally || this.ui.anim.active) return;
        const cs = this.scrutin.candidates;
        const pairs = [];
        for(let i = 0; i < cs.length; i++)
            for(let j = i+1; j < cs.length; j++)
                pairs.push([cs[i], cs[j]]);
        const ballots = this.scrutin.ballots.slice();
        if(pairs.length === 0 || ballots.length === 0) return;
        this.ui.anim.full = this.tally.pairwise;
        this.ui.anim.pairs = pairs;
        this.ui.anim.ballots = ballots;
        this.ui.anim.total = pairs.length * ballots.length;
        this.ui.anim.step = 0;
        this.ui.anim.active = true;
        this.ui.anim._lastPair = -1;
        this._animRender();
    },

    animatePlay(){
        if(this.ui.anim.timer) return;
        const interval = 1500 / this.ui.anim.speed;
        this.ui.anim.timer = setInterval(() => {
            if(this.ui.anim.step >= this.ui.anim.total - 1){
                this.animatePause();
                return;
            }
            this.ui.anim.step++;
            this._animRender();
        }, interval);
    },

    animatePause(){
        if(this.ui.anim.timer){
            clearInterval(this.ui.anim.timer);
            this.ui.anim.timer = null;
        }
    },

    animateToggle(){
        if(this.ui.anim.timer){ this.animatePause(); return; }
        if(this.ui.anim.step >= this.ui.anim.total - 1){
            this.ui.anim.step = 0;
            this._animRender();
        }
        this.animatePlay();
    },

    animateNext(){
        if(this.ui.anim.step >= this.ui.anim.total - 1) return;
        this.animatePause();
        this.ui.anim.step++;
        this._animRender();
    },

    animatePrev(){
        if(this.ui.anim.step <= 0) return;
        this.animatePause();
        this.ui.anim.step--;
        this._animRender();
    },

    animateCycleSpeed(){
        const speeds = [1, 2, 0.5];
        const i = speeds.indexOf(this.ui.anim.speed);
        this.ui.anim.speed = speeds[(i + 1) % speeds.length];
        if(this.ui.anim.timer){
            this.animatePause();
            this.animatePlay();
        }
    },

    animateStop(){
        this.animatePause();
        if(this.ui.anim.full) this.tally.pairwise = this.ui.anim.full;
        this.ui.anim.active = false;
        this.ui.anim.legend = '';
        this.ui.anim.pairs = [];
        this.ui.anim.ballots = [];
        this.ui.anim.breakdown = [];
        this.ui.anim.step = 0;
        this.ui.anim.total = 0;
        this.ui.anim.full = null;
        this.ui.anim._lastPair = -1;
    },
});

Voter à l’aveugle : le secret du bulletin

Pendant qu’un votant classe ses candidats, il ne doit voir aucun bulletin déjà soumis, et en mode appareil partagé, la liste des candidats doit être réinitialisée et mélangée pour chaque votant.

Voter en mode appareil partagé

En mode partagé, tout le monde vote sur le même téléphone. Deux contraintes : le votant qui prend l’appareil ne doit pas voir le classement du précédent (sinon il est influencé), et l’ordre des votants n’est pas imposé — chacun se sert quand il est prêt.

La liste des votants assume les deux rôles à la fois. Entre deux votes, elle est seule visible : chaque nom est un bouton, le coché « a voté » grise les noms déjà passés, et un × à droite retire un nom typé par erreur ou parti avant la fin (cf. Retirer un votant en cours de route). Aucun classement, aucun bulletin n’apparaît — la liste fait office d’écran-tampon naturel. On tend l’appareil au suivant, qui clique son nom, classe ses candidats par drag-and-drop, soumet, et son nom passe à coché à son tour.

Les noms déjà cochés sont désactivés. Ce n’est pas une exigence de secret — qui a voté n’est pas un secret entre amis — mais une protection contre l’écrasement accidentel d’un bulletin déjà soumis.

<div class="identity-screen"
     v-show="scrutin && stage === 'identify'"
     v-cloak>
  <h2 v-show="nextVoter">Qui es-tu&nbsp;?</h2>
  <p class="hint" v-show="nextVoter">Choisis ton nom pour commencer à voter.</p>
  <ul class="identity-list">
    <template v-for="v in (scrutin ? scrutin.voters : [])" :key="'i'+v">
      <li>
        <button class="identity-pick" @click="chooseIdentity(v)"
                :disabled="hasBallotFor(v)">
          <span class="name" v-text="v"></span>
          <span class="status" v-show="hasBallotFor(v)">a voté</span>
        </button>
        <button class="identity-remove" type="button"
                @click="removeVoter(v)"
                v-show="scrutin.voters.length > 1"
                aria-label="Retirer ce votant">&times;</button>
      </li>
    </template>
  </ul>
  <p class="counter">
    <span v-text="ballotsCount"></span> / <span v-text="rosterCount"></span> bulletins
  </p>
</div>

Une fois un nom choisi, l’écran de classement remplace la liste : les candidats dans un ordre aléatoire regénéré à chaque votant (cf. règle de secret). Le votant réordonne par drag-and-drop puis envoie ; retour à la liste des votants, son nom coché.

Chaque item du classement porte deux grips identiques — un à gauche, un à droite — pour que le pouce trouve sa poignée sans déplacer le téléphone, quelle que soit la main qui tient l’appareil (et le réflexe marche aussi pour un gaucher).

Le drag-and-drop est piloté par un moteur partagé (cf. shared_blocks.org) qui émet un événement à chaque déplacement ; le store s’y abonne pour refléter le nouvel ordre dans son état local. Les grips multiples par item sont gérés par le moteur sans code JS spécifique côté scrutin.

<div id="voteFlow" v-show="scrutin && !scrutin.closed" v-cloak>

  <!-- Écran de classement -->
  <div class="ballot-screen" v-show="stage === 'ballot'">
    <h2>Ton classement, <span v-text="currentVoter"></span></h2>
    <p class="hint">Glisse pour ordonner du plus préféré (en haut) au moins préféré.</p>
    <div class="ballot-toolbar">
      <button type="button" class="layout-toggle" @click="toggleBallotLayout()"
              :aria-label="ui.ballotLayout === 'grid' ? 'Passer en colonne' : 'Passer en grille'"
              :title="ui.ballotLayout === 'grid' ? 'Passer en colonne' : 'Passer en grille'">
        <svg v-if="ui.ballotLayout === 'grid'" width="18" height="14" viewBox="0 0 18 14" fill="currentColor">
          <rect width="18" height="2" rx="1"/>
          <rect y="6" width="18" height="2" rx="1"/>
          <rect y="12" width="18" height="2" rx="1"/>
        </svg>
        <svg v-else width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
          <rect width="5" height="5" rx="1"/>
          <rect x="6.5" width="5" height="5" rx="1"/>
          <rect x="13" width="5" height="5" rx="1"/>
          <rect y="6.5" width="5" height="5" rx="1"/>
          <rect x="6.5" y="6.5" width="5" height="5" rx="1"/>
          <rect x="13" y="6.5" width="5" height="5" rx="1"/>
          <rect y="13" width="5" height="5" rx="1"/>
          <rect x="6.5" y="13" width="5" height="5" rx="1"/>
          <rect x="13" y="13" width="5" height="5" rx="1"/>
        </svg>
      </button>
    </div>
    <ol class="ranking reorder-list" id="ranking" data-reorder="ranking"
        :class="{'layout-column': ui.ballotLayout === 'column'}"
        @vue:mounted="bindRankReorder($el)">
      <template v-for="(c, i) in ui.ranking" :key="c">
        <li class="reorder-item" :data-idx="i" :data-candidate="c">
          <span class="reorder-grip" aria-label="Glisser pour réordonner">&#x283F;</span>
          <span class="reorder-rank" v-text="i + 1"></span>
          <img class="reorder-image" v-if="scrutin.candidateImages && scrutin.candidateImages[c]"
               :src="scrutin.candidateImages[c]" alt="">
          <span class="reorder-name" v-text="c"></span>
          <span class="reorder-grip" aria-label="Glisser pour réordonner">&#x283F;</span>
        </li>
      </template>
    </ol>
    <div class="ballot-actions">
      <button class="secondary" @click="cancelBallot()">Annuler</button>
      <button class="primary" id="btnSubmit" @click="submitBallot()">Envoyer mon bulletin</button>
    </div>
  </div>

</div>

.ballot-screen{padding:20px}
.ballot-screen h2{margin:0 0 4px 0}
.ballot-screen .hint{color:var(--muted);font-size:.85rem;margin-bottom:16px}
.ranking{list-style:none;padding:0}
.ballot-toolbar{display:flex;justify-content:flex-end;margin-bottom:8px}
.layout-toggle{background:transparent;color:var(--muted);padding:6px 10px;border:1px solid #333;border-radius:6px;display:inline-flex;align-items:center}
.layout-toggle:hover{color:var(--fg);background:rgba(255,255,255,.05)}
.layout-toggle svg{display:block}
.ballot-screen .reorder-list{flex-direction:row;flex-wrap:wrap}
.ballot-screen .reorder-item{flex:1 1 280px;min-width:0}
.ballot-screen .reorder-list.layout-column{flex-direction:column;flex-wrap:nowrap}
.ballot-screen .reorder-list.layout-column .reorder-item{flex:0 0 auto}
.ballot-actions{display:flex;gap:12px;margin-top:20px}
.ballot-actions button{flex:1}

Le flux complet du mode partagé : chacun choisit son nom dans la liste, classe, soumet, et son nom passe à coché « a voté ». L’ordre n’est pas imposé — le test prend volontairement Bob avant Alice pour s’en assurer. À deux bulletins le bouton Dépouiller s’active. Le titre du scrutin reste en bandeau au-dessus, sur l’identification comme sur le classement.

@testcase
def test_shared_device_flow(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
    assert page.locator(".scrutin-title").inner_text() == "T"
    pick_identity(page, "Bob")
    assert page.locator(".scrutin-title").inner_text() == "T"
    page.evaluate("window.testForceRanking(['B', 'C', 'A'])")
    submit_ballot(page)
    identity_text = page.locator(".identity-screen").inner_text()
    assert "Bob" in identity_text and "a voté" in identity_text
    pick_identity(page, "Alice")
    page.evaluate("window.testForceRanking(['A', 'C', 'B'])")
    submit_ballot(page)
    page.locator("#btnClose").wait_for(state="visible")
    print("  PASS: shared-device flow")

La contrainte de secret est absolue : pendant que Bob attend son tour, il ne doit pas voir les candidats ni pouvoir inférer le classement d’Alice. Rien ne doit fuiter dans l’écran-tampon.

@testcase
def test_ballot_secrecy(page):
    clear_state(page)
    create_scrutin(page, candidates=["Kebab", "Sushi"], voters=["Alice", "Bob"])
    take_phone(page)
    page.evaluate("window.testForceRanking(['Sushi', 'Kebab'])")
    page.click("#btnSubmit")
    visible = page.locator(".identity-screen").inner_text()
    assert "Kebab" not in visible
    assert "Sushi" not in visible
    print("  PASS: ballot secrecy")

Après qu’Alice ait réordonné et soumis son bulletin, Bob doit voir une liste propre : exactement les bons candidats, une fois chacun, rangs 1..N contigus — pas de fantôme ni de doublon hérités des manipulations d’Alice.

@testcase
def test_ballot_clean_between_voters(page):
    """Scénario utilisateur : Alice classe ses candidats selon sa
    préférence, soumet, puis Bob prend le téléphone. L'écran de Bob
    doit montrer exactement les candidats (une fois chacun, rangs
    1..N) --- pas de résidu des manipulations d'Alice."""
    clear_state(page)
    cands = ["rouge", "vert", "bleu"]
    create_scrutin(page, candidates=cands, voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    assert_ballot_clean(page, cands)
    preference = ["bleu", "rouge", "vert"]
    # Précondition : avec la graine pinnée, si le shuffle initial coïncide
    # avec la préférence, =rank_as= ne fait aucun drag --- le test passerait
    # trivialement. Rejeter ce cas force à choisir une autre graine.
    assert ballot_items(page) != preference, \
        "shuffle initial == préférence : le test n'exerce pas de drag"
    rank_as(page, preference)
    submit_ballot(page)
    pick_identity(page, "Bob")
    assert_ballot_clean(page, cands)
    print("  PASS: ballot clean between voters")

Un votant qui n’a pas fini son classement doit pouvoir passer le téléphone et reprendre plus tard exactement où il en était — sinon il faut tout reclasser quand on se laisse devancer, et le partage à l’apéro devient pénible. Chaque réorganisation sauve donc le classement courant dans le doc Automerge sous drafts[voter], qui suit la sync : Annuler n’a rien de spécial à faire, c’est déjà persisté ; un F5 accidentel, ou la fermeture / ré-ouverture de la page, restitue l’ordre laissé en plan ; et en per-device, on retrouve le brouillon en ouvrant le scrutin sur un autre appareil avec la même identité (téléphone qui décharge → on bascule sur l’ordi). Quand le votant clique son nom à nouveau, on restaure le brouillon au lieu de mélanger. À la soumission, le brouillon est effacé.

Le brouillon vit donc dans le doc partagé : techniquement, n’importe quel participant peut l’inspecter via IndexedDB. La contrainte de secret du bulletin (cf. Secret du bulletin) tient côté UI — l’app n’affiche jamais les drafts des autres ; pour le reste, on est entre amis, comme pour le bulletin final.

@testcase
def test_resume_later(page):
    """Mode partagé : Alice annule en cours, Bob vote, Alice
    reprend ; l'ordre où elle s'était arrêtée est restauré."""
    clear_state(page)
    cands = ["A", "B", "C"]
    create_scrutin(page, candidates=cands, voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    alice_partial = ["C", "A", "B"]
    # Si le shuffle initial coïncide déjà avec l'ordre voulu,
    # =rank_as= ne fait aucun drag --- le test passerait sans
    # avoir réordonné. On rejette ce cas pour forcer une vraie
    # manipulation.
    assert ballot_items(page) != alice_partial, \
        "shuffle initial == ordre cible : le test n'exerce pas de drag"
    rank_as(page, alice_partial)
    cancel_ballot(page)
    pick_identity(page, "Bob")
    rank_as(page, ["B", "A", "C"])
    submit_ballot(page)
    pick_identity(page, "Alice")
    assert ballot_items(page) == alice_partial, \
        f"ordre d'Alice non restauré : {ballot_items(page)}"
    submit_ballot(page)
    page.locator("#btnClose").wait_for(state="visible")
    print("  PASS: resume later")

@testcase
def test_partial_survives_reload(page):
    """Mode partagé : un classement en cours est persisté en
    continu --- un =F5= avant soumission ne perd pas l'ordre."""
    clear_state(page)
    cands = ["A", "B", "C"]
    create_scrutin(page, candidates=cands, voters=["Alice"])
    pick_identity(page, "Alice")
    target = ["C", "A", "B"]
    assert ballot_items(page) != target, \
        "shuffle initial == ordre cible : le test n'exerce pas de drag"
    rank_as(page, target)
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    pick_identity(page, "Alice")
    assert ballot_items(page) == target, \
        f"ordre perdu après reload : {ballot_items(page)}"
    print("  PASS: partial survives reload")

@testcase
def test_partial_syncs_across_devices(page):
    """Per-device : Alice classe sur un appareil, ouvre le scrutin
    sur un autre avec la même identité, retrouve son ordre."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B", "C"],
                       voters=["Alice", "Bob"], mode="per-device")
        doc_url = pageA.url
        pageA.locator(".identity-screen button:has-text('Alice')").click()
        target = ["C", "A", "B"]
        assert ballot_items(pageA) != target, \
            "shuffle initial == ordre cible : le test n'exerce pas de drag"
        rank_as(pageA, target)
        pageA.wait_for_timeout(500)  # propagation sync

        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        pageB.locator(".identity-screen").wait_for(state="visible")
        pageB.locator(".identity-screen button:has-text('Alice')").click()
        assert wait_for(pageB, lambda: ballot_items(pageB) == target), \
            f"brouillon non synchronisé : {ballot_items(pageB)}"
    print("  PASS: partial syncs across devices")

Côté store, le clic sur un nom de la liste appelle chooseIdentity, qui enchaîne directement sur startBallot : on prend le brouillon partiel de ce votant s’il existe, sinon on mélange les candidats, et on bascule à stage = 'ballot'. À la soumission, on revient sur la liste (stage = 'identify'), où le nom du votant apparaît coché grâce à hasBallotFor, et le brouillon partiel est effacé. La branche per-device de chooseIdentity s’occupe de la persistance de l’identité (cf. Identité persistée).

Annuler en cours de bulletin renvoie sur la liste. En per-device, on en profite pour effacer l’identité du localStorage, car la personne qui annule peut avoir besoin de laisser quelqu’un d’autre voter sur le même appareil.

function shuffled(arr){
    const a = arr.slice();
    for(let i = a.length - 1; i > 0; i--){
        const j = Math.floor(Math.random() * (i + 1));
        [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
}

Object.assign(voteStore.ui, {
    ranking: [],
    ballotLayout: localStorage.getItem('condorcet.ballot-layout') || 'grid',
});

Object.assign(voteStore, {
    toggleBallotLayout(){
        this.ui.ballotLayout = this.ui.ballotLayout === 'grid' ? 'column' : 'grid';
        localStorage.setItem('condorcet.ballot-layout', this.ui.ballotLayout);
    },
});

Object.assign(voteStore, {
    currentVoter: null,

    // Parametré → une méthode, pas un getter.
    hasBallotFor(name){
        return !!this.scrutin && this.scrutin.ballots.some(b => b.voter === name);
    },

    loadPartial(voter){
        const drafts = (this.scrutin && this.scrutin.drafts) || {};
        const draft = drafts[voter];
        if(!draft) return null;
        const order = draft.ranking;
        const candidates = this.scrutin.candidates;
        if(!Array.isArray(order) || order.length !== candidates.length) return null;
        const cs = new Set(candidates);
        if(!order.every(c => cs.has(c)) || new Set(order).size !== order.length) return null;
        return order;
    },

    savePartial(voter, ranking){
        this.change(d => {
            if(!d.drafts) d.drafts = {};
            d.drafts[voter] = { ranking, at: Date.now() };
        });
    },

    clearPartial(voter){
        this.change(d => {
            if(d.drafts && d.drafts[voter]) delete d.drafts[voter];
        });
    },

    chooseIdentity(name){
        if(this.scrutin.mode === 'per-device'){
            this.identity = name;
            localStorage.setItem(this.identityKey(), name);
            if(this.hasBallotFor(name)){ this.stage = 'waiting'; return; }
        }
        this.startBallot(name);
    },

    startBallot(voter){
        this.currentVoter = voter || this.nextVoter;
        this.ui.ranking = this.loadPartial(this.currentVoter)
            || shuffled(this.scrutin.candidates);
        this.stage = 'ballot';
        pushOverlay('ballot');
    },

    cancelBallot(){
        this._doCancelBallot();
        if(history.state && history.state.overlay === 'ballot') history.back();
    },

    _doCancelBallot(){
        this.currentVoter = null;
        this.ui.ranking = [];
        if(this.scrutin && this.scrutin.mode === 'per-device'){
            this.identity = null;
            localStorage.removeItem(this.identityKey());
        }
        this.stage = 'identify';
    },

    submitBallot(){
        const ranking = this.ui.ranking.slice();
        const voter = this.currentVoter;
        this.change(d => {
            const existing = d.ballots.findIndex(b => b.voter === voter);
            const ballot = { voter, ranking, at: Date.now() };
            if(existing >= 0) d.ballots[existing] = ballot;
            else d.ballots.push(ballot);
            if(d.drafts && d.drafts[voter]) delete d.drafts[voter];
        });
        this.currentVoter = null;
        this.ui.ranking = [];
        this.stage = this.scrutin.mode === 'per-device' ? 'waiting' : 'identify';
        if(history.state && history.state.overlay === 'ballot') history.back();
    },

    bindRankReorder(ol){
        const store = this;
        ol.addEventListener('reorder:move', e => {
            const order = store.ui.ranking.slice();
            const [moved] = order.splice(e.detail.from, 1);
            order.splice(e.detail.to, 0, moved);
            store.ui.ranking = order;
            if(store.currentVoter) store.savePartial(store.currentVoter, order);
        });
    },
});

Chacun sur son téléphone : le mode per-device

Chacun ouvre le lien du scrutin sur son propre téléphone et vote séparément. La synchronisation est assurée via WebSocket Automerge vers le serveur automergesync, avec identification mémorisée en localStorage.

Voter en mode un appareil par personne

En per-device, chacun est sur son propre appareil. La même liste des votants sert ici d’écran d’identification : chacun choisit son nom une fois, et c’est tout. Mémoriser ce choix dans localStorage évite de redemander l’identité au reload — ce serait irritant en plein vote. La clé est scopée à l’URL du scrutin pour qu’il n’y ait pas de collision entre scrutins distincts sur le même appareil.

Une fois le bulletin envoyé, on ne renvoie pas sur la liste d’identification : l’identité est déjà connue, et la propre liste des votants est inutile au votant qui a fini. À la place, un écran d’attente confirme le bulletin et montre qui a voté — information sociale minimale, acceptable entre amis. Aucun classement n’est révélé. Le bandeau du titre reste en place.

<div class="waiting-screen"
     v-show="scrutin && !scrutin.closed && scrutin.mode === 'per-device' && stage === 'waiting'"
     v-cloak>
  <h2>Merci, <span v-text="identity"></span>.</h2>
  <p v-show="nextVoter">Ton bulletin est enregistré. En attente des autres…</p>
  <p class="counter">
    <span v-text="ballotsCount"></span> / <span v-text="rosterCount"></span> bulletins
  </p>
  <ul class="voters-list">
    <template v-for="v in (scrutin ? scrutin.voters : [])" :key="'w'+v">
      <li :class="{voted: hasBallotFor(v)}">
        <span v-text="v"></span>
        <span class="check" v-show="hasBallotFor(v)"></span>
      </li>
    </template>
  </ul>
</div>

.identity-screen,.waiting-screen{text-align:center;padding:40px 20px}
.identity-screen h2,.waiting-screen h2{font-size:1.3rem;margin:0 0 8px 0}
.identity-screen .hint{color:var(--muted);font-size:.9rem;margin-bottom:20px}
.identity-list{list-style:none;padding:0;margin:0 0 20px 0}
.identity-list li{display:flex;gap:6px;margin-bottom:8px}
.identity-pick{flex:1;text-align:left;display:flex;justify-content:space-between;align-items:center;padding:14px 16px;background:var(--card);color:var(--fg)}
.identity-pick .status{color:var(--muted);font-size:.85rem;font-weight:400}
.identity-pick:disabled .name{color:var(--muted)}
.identity-remove{flex-shrink:0;padding:0 14px;background:var(--card);border-radius:6px;color:var(--muted);font-size:1.2rem;line-height:1}
.waiting-screen .counter{margin:16px 0 8px 0;color:var(--muted);font-size:.85rem}
.voters-list{list-style:none;padding:0;margin:8px auto 0 auto;text-align:left;max-width:260px}
.voters-list li{display:flex;justify-content:space-between;padding:6px 12px;border-radius:6px;color:var(--muted)}
.voters-list li.voted{color:var(--fg)}
.voters-list .check{color:var(--ok);font-weight:700}

Le mode per-device n’a de valeur que par la sync : A vote sur son téléphone, B voit le compteur se mettre à jour sur le sien sans rechargement. Le bulletin de B doit se propager jusqu’à A via WebSocket et faire apparaître le bouton Dépouiller chez A.

@testcase
def test_per_device_sync(page):
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B"], voters=["Alice", "Bob"],
                       mode="per-device")
        doc_url = pageA.url
        pageA.locator(".identity-screen button:has-text('Alice')").click()
        pageA.evaluate("window.testForceRanking(['A', 'B'])")
        pageA.click("#btnSubmit")
        pageA.wait_for_timeout(500)
        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        pageB.locator(".identity-screen").wait_for(state="visible")
        pageB.locator(".identity-screen button:has-text('Bob')").click()
        pageB.evaluate("window.testForceRanking(['B', 'A'])")
        pageB.click("#btnSubmit")
        assert wait_for(pageA, lambda: pageA.locator("#btnClose").is_visible()), \
            "A n'a pas vu l'état all-voted propagé"
    print("  PASS: per-device sync")

Le mode per-device ne justifie pas un sous-système séparé : la divergence avec le mode partagé tient à quelques lignes de branchement sur scrutin.mode == ‘per-device’= dans chooseIdentity, cancelBallot et submitBallot. Le bloc per-device gère seulement ce qui lui est propre — l’identité attachée à un navigateur, sa clé localStorage, et la rehydratation au boot qui saute l’écran d’identification quand l’identité est déjà connue.

Object.assign(voteStore, {
    identity: null,  // le votant attaché à CE navigateur

    identityKey(){ return 'condorcet.identity.' + _handle.url; },

    initStagePerDevice(doc){
        const stored = localStorage.getItem(this.identityKey());
        if(stored && doc.voters.includes(stored)){
            this.identity = stored;
            if(doc.ballots.some(b => b.voter === stored)){
                this.stage = 'waiting';
            } else {
                this.startBallot(stored);
            }
        } else {
            this.stage = 'identify';
        }
    },
});

Bouton Dépouiller partagé

Les écrans hors-bulletin diffèrent selon le mode (l’identity-screen seule en partagé ; identity + waiting en per-device), mais le déclencheur “Dépouiller” est le même partout : dès que tout le monde a voté, n’importe qui peut clore le scrutin. On factorise donc ce bouton dans une close-bar unique, montrée sous l’écran courant dès que plus personne n’a à voter.

<div class="close-bar"
     v-show="scrutin && !scrutin.closed && allVoted && stage !== 'ballot'"
     v-cloak>
  <p>Tout le monde a voté.</p>
  <button class="primary" id="btnClose" @click="close()">Dépouiller</button>
</div>

.close-bar{text-align:center;padding:20px;border-top:1px solid #333;margin-top:20px}
.close-bar p{margin:0 0 12px 0;color:var(--muted)}

Classer beaucoup de candidats

À une dizaine de candidats, le ballot dépasse l’écran. Pour faire passer un nom du haut vers le bas, il faut relâcher le drag à mi-chemin pour scroller, re-saisir, déplacer encore — la boucle scroll-drag-scroll-drag est fatigante et casse le geste. Le moteur reorder partagé (cf. Reorder engine) auto-scrolle dès que le pointeur s’approche du bord haut ou bas du viewport. On garde le doigt appuyé, la page suit, le slot grisé reste collé à l’élément traîné, et on lâche au bon endroit.

@testcase
def test_ballot_auto_scroll(page):
    """Tenir le drag près du bord bas fait défiler la page toute seule,
    l'item traîné atterrit dans le dernier quintile (inatteignable sans
    scroll automatique), et le slot grisé reste sur lui pendant tout le
    trajet."""
    clear_state(page)
    candidates = [f"C{i:02d}" for i in range(20)]
    create_scrutin(page, candidates=candidates, voters=["Alice"])
    pick_identity(page, "Alice")
    page.locator(".ballot-screen").wait_for(state="visible")

    page.evaluate("window.scrollTo(0, 0)")
    assert page.evaluate("window.scrollY") == 0

    items = page.locator(".reorder-list .reorder-item")
    first_name = items.first.locator(".reorder-name").inner_text()
    box = items.first.locator(".reorder-grip").first.bounding_box()
    page.mouse.move(box["x"] + box["width"]/2, box["y"] + box["height"]/2)
    page.mouse.down()
    vp = page.viewport_size
    page.mouse.move(box["x"] + box["width"]/2, vp["height"] - 20)

    # On attend un scroll important : tenir au bord pendant ~half-second
    # déplace la page de plusieurs centaines de px, ce qui pousse le
    # cursor (resté à clientY = vp.bottom-20) bien après l'item le plus
    # bas du viewport initial.
    page.wait_for_function("() => window.scrollY > 300", timeout=5000)

    placeholder = page.locator(".reorder-item.drag-placeholder")
    assert placeholder.count() == 1, \
        f"attendu 1 slot grisé, vu {placeholder.count()}"
    ph_cand = placeholder.get_attribute("data-candidate")
    assert ph_cand == first_name, \
        f"slot grisé sur {ph_cand!r}, attendu l'élément traîné {first_name!r}"

    page.mouse.up()

    final_order = ballot_items(page)
    final_pos = final_order.index(first_name)
    assert final_pos >= len(final_order) - 6, \
        f"{first_name} ended at {final_pos}/{len(final_order)}, expected last 6"
    print("  PASS: ballot auto-scroll near edge")

Classer sur écran large

Sur tablette ou desktop, la colonne unique laisse beaucoup d’espace horizontal vide à côté du bulletin. La liste enroule donc en grille flexible : chaque item garde une largeur minimale lisible, et autant d’items que la viewport en permet se rangent côte à côte. Sur téléphone, un seul tient et la grille redevient naturellement une colonne, sans media query. Le drag-and-drop fonctionne d’une case à l’autre dans toutes les directions, y compris intra-rangée — on saisit un candidat et on le pose là où on veut.

La grille n’est pas toujours souhaitable, même quand elle tient à l’écran : avec peu de candidats elle peut paraître éparpillée, et certains préfèrent simplement lire de haut en bas. Un bouton sur l’écran de classement bascule entre grille et colonne, et le choix est mémorisé dans localStorage pour qu’il survive au prochain scrutin — une préférence qu’on pose une fois, pas un toggle qu’on refait à chaque vote.

@testcase
def test_ballot_grid_on_wide(page):
    """Sur viewport large, au moins deux candidats partagent une ligne,
    et un drag intra-rangée déplace bien le candidat traîné --- cas
    qu'un moteur strictement vertical ne sait pas gérer."""
    page.set_viewport_size({"width": 700, "height": 700})
    try:
        clear_state(page)
        candidates = [f"C{i}" for i in range(4)]
        create_scrutin(page, candidates=candidates, voters=["Alice"])
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items = page.locator(".reorder-list .reorder-item")
        ys = sorted({round(items.nth(i).bounding_box()["y"] / 5) * 5
                     for i in range(4)})
        assert len(ys) < 4, f"liste pas en grille : Y distincts {ys}"

        initial = ballot_items(page)
        sb = items.nth(0).locator(".reorder-grip").first.bounding_box()
        tb = items.nth(1).bounding_box()
        sx = sb["x"] + sb["width"]/2
        sy = sb["y"] + sb["height"]/2
        tx = tb["x"] + tb["width"] - 10
        ty = tb["y"] + tb["height"]/2
        page.mouse.move(sx, sy)
        page.mouse.down()
        for s in range(1, 13):
            page.mouse.move(sx + (tx - sx) * s / 12,
                            sy + (ty - sy) * s / 12)
        page.mouse.up()
        page.wait_for_timeout(180)

        final = ballot_items(page)
        assert final[1] == initial[0], \
            f"après drag intra-rangée, {initial[0]} attendu en pos 1 : {final}"
        print("  PASS: ballot grid intra-row drag")
    finally:
        page.set_viewport_size({"width": 400, "height": 800})

@testcase
def test_ballot_layout_toggle(page):
    """Sur viewport large, le bouton bascule la liste entre grille et
    colonne, et le choix tient au reload."""
    page.set_viewport_size({"width": 700, "height": 700})
    try:
        clear_state(page)
        candidates = [f"C{i}" for i in range(4)]
        create_scrutin(page, candidates=candidates, voters=["Alice"])
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items = page.locator(".reorder-list .reorder-item")

        def distinct_ys():
            return sorted({round(items.nth(i).bounding_box()["y"] / 5) * 5
                           for i in range(4)})

        assert len(distinct_ys()) < 4, \
            f"défaut attendu en grille : {distinct_ys()}"

        page.locator(".ballot-toolbar button").click()
        page.wait_for_timeout(100)
        assert len(distinct_ys()) == 4, \
            f"après toggle, attendu colonne : {distinct_ys()}"

        page.reload()
        page.wait_for_selector("[data-app-ready]")
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items_after = page.locator(".reorder-list .reorder-item")
        ys3 = sorted({round(items_after.nth(i).bounding_box()["y"] / 5) * 5
                      for i in range(4)})
        assert len(ys3) == 4, f"colonne attendue après reload : {ys3}"
        print("  PASS: ballot layout toggle")
    finally:
        page.set_viewport_size({"width": 400, "height": 800})

Quitter un scrutin

Une fois attaché à un scrutin, l’URL ?doc…= maintient l’utilisateur captif : aucun chemin pour rejoindre un autre scrutin de la liste, ou simplement repartir d’une page propre. Éditer la query string à la main n’est pas une option mobile, et fermer l’onglet perd la session. On expose donc un bouton ← Menu présent dès qu’un scrutin est attaché ; un clic ramène à l’empty-state.

@testcase
def test_menu_button_returns_to_main(page):
    """Cliquer ← Menu depuis un scrutin attaché ramène à l'empty-state."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    assert page.locator(".identity-screen").is_visible()

    _role_button(page, "← Menu").click()

    page.locator(".empty-state").wait_for(state="visible")
    assert "doc=automerge:" not in page.url
    print("  PASS: menu button returns to main")

Le scrutin quitté n’est pas perdu : recordScrutin ayant été appelé à l’attache (cf. Liste des scrutins précédents), il apparaît dans la liste sur l’empty-state, prêt à être ré-ouvert.

@testcase
def test_menu_button_keeps_scrutin_in_past(page):
    """Quitter via ← Menu laisse le scrutin dans Scrutins précédents,
    prêt à être ré-ouvert."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="ScrutinX")

    _role_button(page, "← Menu").click()

    page.get_by_role("link", name="ScrutinX").wait_for(state="visible")
    print("  PASS: menu button keeps scrutin in past")

Pendant l’écran de classement le bouton s’efface : Annuler remplit déjà ce rôle pour le bulletin en cours (cf. Voter en mode appareil partagé), et un second escape ferait double emploi sous le pouce du votant.

@testcase
def test_menu_button_hidden_during_ballot(page):
    """Sur le ballot-screen, ← Menu est caché --- =Annuler= prend le relais."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    pick_identity(page, "Alice")
    page.locator(".ballot-screen").wait_for(state="visible")
    assert not _role_button(page, "← Menu").is_visible()
    print("  PASS: menu button hidden during ballot")

Pas de détachement explicite à écrire : retirer ?doc…= de la query suffit, le flux d’init existant remonte alors l’empty-state avec la liste des passages.

Object.assign(voteStore, {
    goToMenu(){
        location.search = '';
    },
});

La barre se cale dans la rangée d’en-tête, en retrait du build-tag fixe à gauche et de l’indicateur de sync à droite pour les laisser occuper les coins sans chevauchement. La couleur muette marque son rôle de raccourci de navigation, hors du flux des actions principales — Partager, QR, Dépouiller.

<div class="menu-bar" v-show="scrutin && stage !== 'ballot'" v-cloak>
  <button class="menu-link" @click="goToMenu()">&larr; Menu</button>
</div>

.menu-bar{padding:8px 12px 8px 80px}
.menu-bar .menu-link{padding:6px 12px;font-size:.85rem;background:transparent;color:var(--muted);font-weight:500}

Bouton retour

Sur Android (PWA installée ou navigateur), le bouton retour matériel est le geste de navigation principal. Sans intervention, presser retour quitte l’app — y compris depuis une modale ouverte ou en plein classement, ce qui sacrifie tout le contexte que l’utilisateur a en tête. On hooke donc le bouton retour pour qu’il ferme l’overlay courant : modale de création (= Annuler), modale QR (= Fermer), écran de classement (= Annuler du bulletin, qui sauve le brouillon partiel pour reprise — cf. Voter en mode appareil partagé). Une fois sur l’écran principal sans overlay, retour reprend son comportement par défaut (sortir de l’app ou revenir à la page précédente du navigateur).

@testcase
def test_back_closes_overlays(page):
    """Le bouton retour ferme l'overlay courant au lieu de quitter
    l'app : modale Créer, écran de classement, modale QR."""
    clear_state(page)

    # Modale de création
    _role_button(page, "Créer un scrutin").click()
    assert page.locator("#createModal.open").is_visible()
    page.go_back()
    page.wait_for_function(
        "() => !document.querySelector('#createModal.open')")
    assert _role_button(page, "Créer un scrutin").is_visible()

    # Écran de classement
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    pick_identity(page, "Alice")
    assert page.locator(".ballot-screen").is_visible()
    page.go_back()
    page.locator(".identity-screen").wait_for(state="visible")
    assert not page.locator(".ballot-screen").is_visible()

    # Modale QR
    page.click("#btnQR")
    page.locator(".qr-modal.open").wait_for(state="visible")
    page.go_back()
    page.wait_for_function(
        "() => !document.querySelector('.qr-modal.open')")

    print("  PASS: back closes overlays")

function pushOverlay(name){
    history.pushState({ overlay: name }, '');
}

function syncOverlaysFromHistory(store){
    const overlay = history.state && history.state.overlay;
    if(store.ui.qrOpen && overlay !== 'qr') store._doCloseQR();
    if(store.ui.createOpen && overlay !== 'create') store._doCancelCreate();
    if(store.stage === 'ballot' && overlay !== 'ballot') store._doCancelBallot();
}

Plumbing

Schéma du document Automerge

Un doc par scrutin. Pas de doc racine qui liste tous les scrutins — chaque scrutin vit sous sa propre URL automerge:....

{
  title: string,
  candidates: string[],                // l'ordre de création, immuable
  candidateImages: {[name]: string},   // dataURL JPEG compressée ; optionnel par candidat
  voters: string[],                    // le roster, mutable (cf. [[#c0d07c0c-0017-4000-8000-000000000000][ajout]] / [[#c0d07c0c-004a-4000-8000-000000000000][retrait]] en cours de route)
  ballots: { voter: string, ranking: string[], at: number }[],
  drafts: {[voter]: { ranking: string[], at: number }},  // brouillons en cours, partagés via la sync (cf. [[#c0d07c0c-0013-4000-8000-000000000000][Voter en mode appareil partagé]])
  closed: boolean,
  mode: 'shared-device' | 'per-device',
  method: 'condorcet-random-smith',
  createdAt: number,
}

Un bulletin par votant : un nouveau vote écrase l’ancien en cherchant par voter.

Repo Automerge, persistance IndexedDB, sync optionnel

Chaque scrutin est un doc Automerge indépendant — pas de doc racine qui listerait tous les scrutins. Son URL automerge:<hash> est portée dans la query string, ce qui rend le partage trivial : donner l’URL, c’est inviter. IndexedDB garde une copie locale pour l’offline et la persistance entre sessions.

La sync WebSocket est optionnelle : sans ?sync_url, l’app tourne entièrement en local, ce qui convient au mode partagé et aux tests. Le sync_url est mémorisé dans localStorage après le premier visit pour que les liens partagés sans ce paramètre synchent quand même.

Un utilisateur ne doit jamais voir une app bloquée en chargement à cause d’un doc lent à arriver. Le chargement initial attend au plus 3 s (IndexedDB répond en quelques ms, WebSocket peut traîner) ; passé ce délai, l’app devient interactive sans attendre, et le store se remplira quand le doc arrivera via l’événement change. Le serveur de sync est décrit dans Serveur de sync automergesync.

Quand la sync est armée, l’état de la liaison avec le serveur n’est pas un détail interne : l’utilisateur a besoin de savoir si son bulletin partira vraiment ou restera en local (cf. Indicateur de connexion). makeRepo prend donc un callback que l’adapter alimente via les événements peer-candidate et peer-disconnected du protocole NetworkAdapter — câblés dès la création de l’adapter pour que la première transition soit captée même si l’UI s’abonne tard.

Automerge + ses adapters pèsent ~1.8 MiB de WASM et ~50 KiB de JS au-delà de l’app elle-même. Les charger statiquement bloque le premier paint sur tout le téléchargement. On les charge donc en dynamic import : l’empty-state se rend avec juste petite-vue + qrcode, et les modules Automerge n’arrivent qu’au moment où on en a besoin (URL avec ?doc…=, clic sur Créer un scrutin, import depuis URL). loadAutomerge memoize le promise pour que les appels concurrents partagent une seule fetch ; ensureRepo fait la même chose au-dessus pour la Repo construite.

// Modules Automerge chargés dynamiquement à la première demande.
// Avec rspack + =experiments.asyncWebAssembly=, l'entrée
// =fullfat_bundler= se câble toute seule : =import * from "*.wasm"=
// produit un module JS dont les exports sont les fonctions
// wasm-bindgen, le WASM est instancié à l'import dynamique.
let _automergePromise = null;
function loadAutomerge(){
    if(!_automergePromise){
        _automergePromise = (async () => {
            const [repoMod, idbMod, wsMod] = await Promise.all([
                import('@automerge/automerge-repo'),
                import('@automerge/automerge-repo-storage-indexeddb'),
                import('@automerge/automerge-repo-network-websocket'),
            ]);
            return {
                Repo: repoMod.Repo,
                isValidAutomergeUrl: repoMod.isValidAutomergeUrl,
                IndexedDBStorageAdapter: idbMod.IndexedDBStorageAdapter,
                BrowserWebSocketClientAdapter: wsMod.BrowserWebSocketClientAdapter,
            };
        })();
    }
    return _automergePromise;
}

async function makeRepo(onSync){
    const am = await loadAutomerge();
    const params = new URLSearchParams(location.search);
    const fromParam = params.get('sync_url');
    if(fromParam){
        localStorage.setItem('condorcet.sync_url', fromParam);
        const clean = new URL(location);
        clean.searchParams.delete('sync_url');
        history.replaceState(null, '', clean);
    }
    const syncUrl = localStorage.getItem('condorcet.sync_url');
    let adapter = null;
    if(syncUrl){
        adapter = new am.BrowserWebSocketClientAdapter(syncUrl);
        adapter.on('peer-candidate', () => onSync?.('connected'));
        adapter.on('peer-disconnected', () => onSync?.('disconnected'));
        onSync?.('connecting');
    } else {
        onSync?.('off');
    }
    return new am.Repo({
        storage: new am.IndexedDBStorageAdapter('condorcet'),
        network: adapter ? [adapter] : [],
    });
}

let _repoPromise = null;
function ensureRepo(reactiveStore){
    if(!_repoPromise){
        _repoPromise = makeRepo(s => { reactiveStore.syncStatus = s; })
            .then(repo => { _repo = repo; return repo; });
    }
    return _repoPromise;
}

async function loadInitialHandle(repo){
    const am = await loadAutomerge();
    const urlParam = new URLSearchParams(location.search).get('doc');
    if(!urlParam || !am.isValidAutomergeUrl(urlParam)) return null;
    const handle = repo.find(urlParam);
    await Promise.race([
        handle.whenReady(['ready', 'unavailable']).catch(() => {}),
        new Promise(r => setTimeout(r, 3000)),
    ]);
    return handle;
}

Bundler avec rspack

L’app doit démarrer hors-ligne : à l’apéro, le wifi est capricieux, et un téléphone qui ne peut pas voter parce qu’un CDN ne répond pas, c’est une panne produit. Tout est donc bundlé localement, pas de fetch esm.sh au runtime. Bénéfice dérivé : les tests sont déterministes, la suite ne dépend pas du réseau.

Le choix du bundler est contraint par Automerge : son entrée fullfat_bundler importe le WASM via import * as wasm from "./automerge_wasm_bg.wasm", un pattern spécifique à experiments.asyncWebAssembly de webpack. bun et esbuild ne l’instancient pas correctement. On choisit rspack — compatible webpack, plus rapide, plus simple à configurer.

Sans précaution, rspack émet deux copies du même WASM Automerge (~1.8 MiB chacune). Le premier est instancié au runtime via la voie fullfat_bundler (import * as wasm) ; le second vient d’un new URL("automerge_wasm_bg.wasm", import.meta.url) dans low_level.js, présent uniquement pour les utilisateurs de l’entrée /slim qui appellent eux-mêmes initializeWasm(). On n’en fait rien — on alias donc le chemin web/automerge_wasm.js sur un petit stub qui re-export l’API du bundler. Seul le binaire utilisé reste émis. Le stub doit aussi exporter un default (même nul) : low_level.js fait import { default as initWasm }, et sans cet export rspack refuse le link avec ESModulesLinkingError. On ne l’appelle jamais (fullfat_bundler invoque UseApi(api) directement) ; le binding est purement structurel.

Le nom du WASM est figé à automerge.wasm via output.webassemblyModuleFilename : le hash par défaut change à chaque build, ce qui rendrait impossible un <link rel“preload”>= en HTML. Avec un nom stable, on peut amorcer le téléchargement en parallèle de bundle.js (cf. Service worker et préchargement WASM).

Service worker et préchargement WASM

Sans intervention, chaque ouverture de l’app re-télécharge ~2 MiB (bundle.js + WASM Automerge). Sur 4G ce sont plusieurs secondes visibles avant l’écran initial. Deux leviers, indépendants :

  1. À la première visite, sans amorce le WASM ne commence à se télécharger qu’après le parse de bundle.js et l’évaluation de ses imports : deux RTT en série. Un <link rel“preload”>= dans le <head> avec le nom stable fixé par le bundler (cf. Bundler avec rspack) les rend concurrents.

  2. Aux visites suivantes, on ne veut rien re-télécharger. On branche le service worker partagé du recueil (cf. Service worker (cache-first + write-through)) : sa stratégie cache-first / write-through colle exactement à nos assets (HTML qu’on veut frais, bundle.js et WASM qu’on veut figés tant que le build ne change pas), et son sw-register coupe l’enregistrement sur localhost ce qui protège les tests d’un cache piégé.

Reste à fixer ce qui est spécifique à cette app : le preload pointe vers automerge.wasm, et le SW déclare CACHE + ASSETS avant d’inclure les handlers partagés. La coquille précachée se limite à ./ et index.html : bundle.js et automerge.wasm se cachent au premier fetch via la branche write-through, ce qui évite un double round-trip à l’install.

Pour invalider proprement, on sépare deux pools : un pour la coquille app, un pour les assets vendor (WASM Automerge + chunks lazy d’automerge-repo). Les deux ont des cycles de vie indépendants : la coquille change à chaque modif de notre code (root, app-js, sw, manifest via build-hash en tête de doc, cf. Hash de build) ; le vendor change uniquement quand on bumpe une dépendance, ce que package-json reflète. On rappelle build-hash avec la liste de blocs adéquate pour chaque pool. Conséquence : éditer un libellé d’UI ne force pas l’utilisateur à re-télécharger 1.8 MiB de WASM, et bumper Automerge n’évincte pas la coquille HTML précachée pour l’offline. Le routage entre pools est piloté par regex côté shared SW (cf. Service worker (cache-first + write-through)) : tout .wasm ou *.bundle.js part dans le pool vendor, le reste dans le pool app.

<link rel="preload" as="fetch" type="application/wasm" crossorigin
      href="automerge.wasm">
nil

const CACHES = [
    { pattern: /\.wasm$|\.bundle\.js$/,
      name:    'condorcet-vendor-nil' },
    { name:    'condorcet-app-nil' },
];
const ASSETS = ['./', './index.html'];

nil

Serveur de sync automergesync

Le mode per-device n’a de valeur que si les bulletins se propagent vraiment entre téléphones : A vote, B voit le compteur s’incrémenter sur son écran, sans avoir à recharger. Automerge gère le CRDT, mais deux navigateurs ne peuvent pas s’envoyer des patches directement — il faut un relais WebSocket. Un serveur minimal, qui ne fait que repasser les messages entre clients, suffit : pas d’auth métier, pas de logique applicative, juste un tuyau CRDT.

On réutilise pour ça le package npm officiel @automerge/automerge-repo-sync-server, empaqueté dans l’image Docker konubinix/automergesync. Côté client, ouvrir une fois l’app avec ?sync_url=wss://<ton-serveur> suffit — la valeur est mémorisée en localStorage et réutilisée aux visites suivantes (cf. test test_sync_url_memorized plus bas).

La sync doit se tester de bout en bout contre le même binaire qu’en prod — sinon un passage vert en test peut cacher un bug de version en prod. La fixture require_sync_server (cf. Fixture Docker) lance l’image officielle au premier test qui en a besoin.

La sync WebSocket n’est utile que si les mutations d’un navigateur arrivent vraiment dans l’autre : A soumet un bulletin, B (dans un contexte isolé) le voit dans son store sans rechargement.

@testcase
def test_sync_propagation(page):
    """Un bulletin soumis dans un navigateur se propage à l'autre via le sync server."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        # A : arme la sync puis crée le scrutin
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
        doc_url = pageA.url  # inclut ?doc=automerge:...
        # Alice vote
        take_phone(pageA)
        pageA.evaluate("window.testForceRanking(['A', 'B', 'C'])")
        pageA.click("#btnSubmit")
        pageA.wait_for_timeout(500)  # flush debounced save + sync
        # B : arme la sync pour son propre localStorage + ouvre le doc
        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        wait_for(pageB, lambda: "Alice" in (pageB.evaluate(BALLOTS_JSON) or ""))
        ballots = pageB.evaluate(BALLOTS_JSON)
        assert ballots and "Alice" in ballots, f"B n'a pas reçu le bulletin : {ballots}"
    print("  PASS: sync propagation")

L’invitation se fait via un magic link cliqué une seule fois. Après ça, les liens de scrutin partagés n’ont pas de sync_url — pourtant la sync doit fonctionner. Mémoriser sync_url dans localStorage au premier visit suffit pour que les visites suivantes synchent automatiquement.

@testcase
def test_sync_url_memorized(page):
    """Après un premier visit avec ?sync_url, les visites ultérieures sans param sync aussi."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B"], voters=["Alice", "Bob"])
        doc_qs = "?" + pageA.url.split("?", 1)[1]  # "?doc=automerge:..."
        take_phone(pageA)
        pageA.evaluate("window.testForceRanking(['A', 'B'])")
        pageA.click("#btnSubmit")
        pageA.wait_for_timeout(500)

        # Étape 1 : visite "magic link" avec ?sync_url, pas de doc.
        pageB = open_app(ctxB, f"{BASE_URL}?sync_url={sync_url}")
        stored = pageB.evaluate("localStorage.getItem('condorcet.sync_url')")
        assert stored == sync_url, f"sync_url non mémorisé : {stored!r}"
        assert "sync_url=" not in pageB.url, f"sync_url non strippé : {pageB.url}"
        # Étape 2 : même page, on navigue vers ?doc=... SANS sync_url
        pageB.goto(f"{BASE_URL}{doc_qs}", timeout=15000)
        pageB.wait_for_selector("[data-app-ready]", timeout=15000)
        # La sync doit se brancher via le sync_url stocké
        wait_for(pageB, lambda: "Alice" in (pageB.evaluate(BALLOTS_JSON) or ""))
        ballots = pageB.evaluate(BALLOTS_JSON)
        assert ballots and "Alice" in ballots, \
            f"B sans sync_url n'a pas reçu le bulletin : {ballots}"
    print("  PASS: sync_url memorized")

Indicateur de connexion

Une fois la sync armée, l’utilisateur doit pouvoir voir en un coup d’œil si son téléphone est en lien avec le serveur. Sans ça, un bulletin qui tarde à apparaître chez l’autre laisse le doute : retard réseau, socket coupée, serveur absent ? Une pastille discrète en haut à droite, lue à la volée, tranche ce doute sans solliciter l’attention quand tout va bien.

Trois états visuels : orange pulsant pendant la connexion, vert une fois le serveur atteint, rouge si la socket se coupe. En l’absence de sync_url armé (off) la pastille reste cachée — afficher un voyant là où rien ne se synchronise inquiéterait pour rien.

La pastille traduit fidèlement le champ syncStatus du store, lui-même alimenté par le callback de makeRepo via les transitions peer-candidate / peer-disconnected de l’adapter (cf. Repo Automerge). Une fois le serveur atteint au moins une fois, la pastille passe au vert ; le test ci-dessous fige cette garantie de bout en bout.

@testcase
def test_sync_indicator(page):
    """L'indicateur passe à 'connected' (pastille verte) après handshake serveur."""
    sync_url = require_sync_server()
    page.goto(f"{BASE_URL}?sync_url={sync_url}", timeout=15000)
    page.wait_for_selector("[data-app-ready]", timeout=15000)
    wait_for(page, lambda: page.evaluate("window.__voteStore.syncStatus") == "connected")
    assert page.locator(".sync-indicator.sync-connected").is_visible(), \
        "pastille verte attendue après handshake"
    print("  PASS: sync indicator")

Le markup expose la classe sync-<status> pour que le CSS pilote couleur et animation, et syncStatusLabel en title pour le tooltip de bureau. pointer-events:none empêche la pastille d’intercepter un tap dans la zone du haut.

<div class="sync-indicator"
     v-show="syncStatus !== 'off'"
     :class="'sync-'+syncStatus"
     :title="syncStatusLabel"
     v-cloak>
  <span class="dot"></span>
  <span class="label" v-text="syncStatusLabel"></span>
</div>

.sync-indicator{position:fixed;top:calc(env(safe-area-inset-top) + 8px);right:8px;display:flex;align-items:center;gap:6px;padding:4px 8px;background:rgba(0,0,0,.35);border-radius:12px;font-size:.72rem;color:var(--muted);pointer-events:none;z-index:50}
.sync-indicator .dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.sync-indicator.sync-connecting .dot{background:var(--accent);animation:sync-pulse 1s ease-in-out infinite}
.sync-indicator.sync-connected .dot{background:var(--ok)}
.sync-indicator.sync-disconnected .dot{background:var(--danger)}
@keyframes sync-pulse{0%,100%{opacity:1}50%{opacity:.3}}

Fixture Docker pour tests de sync

La fixture démarre un container à partir de l’image prod au premier test qui en a besoin (cache partagé entre tests) et l’arrête via atexit.

import atexit, contextlib, shutil, socket, subprocess, tempfile, time, uuid
SYNC_IMAGE = "konubinix/automergesync:0.1.0"
_sync_state = {"fixture": None}

BALLOTS_JSON = ("JSON.stringify((window.__voteStore.scrutin && "
                "window.__voteStore.scrutin.ballots) || [])")

def _free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]

def start_sync_server():
    """Return (container, data_dir, sync_url). Cached across calls."""
    if _sync_state["fixture"] is not None:
        return _sync_state["fixture"]
    if not shutil.which("docker"):
        raise RuntimeError("docker introuvable — requis pour les tests de sync")
    data_dir = tempfile.mkdtemp(prefix="amr-sync-data-")
    container = f"condorcet-sync-{uuid.uuid4().hex[:8]}"
    port = _free_port()
    try:
        subprocess.check_call(
            ["docker", "run", "-d", "--rm",
             "--name", container,
             "-p", f"127.0.0.1:{port}:3030",
             "-e", "PORT=3030",
             "-e", "DATA_DIR=/data",
             "-v", f"{data_dir}:/data",
             SYNC_IMAGE,
             "automerge-repo-sync-server"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except subprocess.CalledProcessError as e:
        shutil.rmtree(data_dir, ignore_errors=True)
        raise RuntimeError(f"échec docker run {SYNC_IMAGE}: {e}") from e
    import urllib.request, urllib.error
    for _ in range(200):
        try:
            urllib.request.urlopen(f"http://localhost:{port}/", timeout=0.5).close()
            _sync_state["fixture"] = (container, data_dir, f"ws://localhost:{port}")
            atexit.register(_stop_sync_server_atexit)
            return _sync_state["fixture"]
        except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
            time.sleep(0.1)
    subprocess.run(["docker", "stop", container],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(data_dir, ignore_errors=True)
    raise RuntimeError(f"sync server n'a pas répondu sur le port {port}")

def require_sync_server():
    return start_sync_server()[2]

@contextlib.contextmanager
def two_contexts(browser, viewport={"width": 400, "height": 800}):
    """Paire de contextes navigateur, fermés en sortie du =with=."""
    ctxA = browser.new_context(viewport=viewport)
    ctxB = browser.new_context(viewport=viewport)
    seed_context(ctxA)
    seed_context(ctxB)
    try:
        yield ctxA, ctxB
    finally:
        ctxA.close()
        ctxB.close()

def _stop_sync_server_atexit():
    fx = _sync_state.get("fixture")
    if not fx:
        return
    container, data_dir, _ = fx
    subprocess.run(["docker", "stop", container],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(data_dir, ignore_errors=True)
    _sync_state["fixture"] = None

Store petite-vue — unique source de vérité réactive

Automerge et petite-vue proposent chacun leur propre modèle d’objet réactif — et ils ne se composent pas. Au lieu d’essayer de les marier, on pose une règle unique : le store ne contient jamais d’objets Automerge, seulement un snapshot cloné en JSON plat après chaque mutation ou réception sync. Une seule source de vérité, un seul point de rafraîchissement. Trois conséquences concrètes en découlent.

Pourquoi JSON plat. Sans clonage, le compteur “n votants / m inscrits” ne s’incrémente pas à l’arrivée d’un bulletin, et la bascule vers l’écran résultat ne se déclenche pas à la clôture : les templates Vue ne voient tout simplement pas le changement. Automerge enveloppe son doc dans un Proxy copy-on-write que la réactivité de Vue ne sait pas retraverser ; cloner en JSON à chaque écriture lui donne un objet ordinaire à observer.

Pourquoi Repo et DocHandle vivent hors du store. Rangés dans le store, ces objets crashent au premier accès à une de leurs méthodes : le Proxy Vue intercepte leurs accès et casse les champs privés de classe ("Receiver must be an instance of…"). Ils restent donc en variables de module, et le store ne voit qu’un snapshot déjà plat.

Pourquoi des flags plats plutôt que des getters pour ce qui dépend de scrutin.ballots. Après un reload, un nouveau bulletin n’allume plus le bouton Dépouiller : les getters qui lisent une propriété en profondeur ne se retracent pas sur un doc rehydraté depuis IndexedDB. On recalcule donc allVoted en dur à chaque mutation plutôt que via un getter.

Les getters qui ne dépendent pas d’une profondeur fragile (nextVoter, rosterCount, ballotsCount) restent en place — la ré-assignation entière de scrutin à chaque mutation suffit à les recalculer. Petit piège en passant : un getter ne doit pas retourner un Set ou un Map, petite-vue tente de les proxifier et échoue (“non-object as target”).

À l’attache, le doc peut ne pas encore être disponible (IndexedDB ou WebSocket en transit). Le store doit rester utilisable dans cet état ; le listener de changements prend le relais dès que le doc arrive.

Corollaire côté templates : v-show ne court-circuite pas l’évaluation réactive des sous-effects. Un v-for“v in scrutin.voters”= dans un parent v-show“scrutin && …"= jette quand même au boot tant que scrutin est null. Les v-for qui descendent dans scrutin se protègent donc avec un fallback explicite (v-for“v in (scrutin ? scrutin.voters : [])") plutôt que de compter sur le =v-show du parent.

Le statut de la liaison sync vit ici aussi, comme champ scalaire plat syncStatus (‘off’, ‘connecting’, ‘connected’, ‘disconnected’) alimenté par le callback de makeRepo (cf. Indicateur de connexion). Pas besoin de getter en profondeur — c’est un scalaire, la réactivité de petite-vue le suit naturellement.

function cloneDoc(doc){
    return doc ? JSON.parse(JSON.stringify(doc)) : doc;
}

let _repo = null;
let _handle = null;

const voteStore = {
    scrutin: null,
    tally: null,
    stage: 'identify',   // 'identify' | 'ballot' | 'waiting'
    allVoted: false,
    syncStatus: 'off',   // 'off' | 'connecting' | 'connected' | 'disconnected'
    loadingDoc: false,   // true tant qu'on attend la résolution d'un =?doc=...= (cf. init lazy)
    ui: {},

    get rosterCount(){ return this.scrutin ? this.scrutin.voters.length : 0; },
    get ballotsCount(){ return this.scrutin ? this.scrutin.ballots.length : 0; },
    get nextVoter(){
        if(!this.scrutin) return null;
        const voted = {};
        for(const b of this.scrutin.ballots) voted[b.voter] = true;
        return this.scrutin.voters.find(v => !voted[v]) || null;
    },
    get syncStatusLabel(){
        return ({
            connecting: 'Connexion au serveur…',
            connected: 'Synchronisé',
            disconnected: 'Hors ligne',
        })[this.syncStatus] || '';
    },

    change(fn){
        _handle.change(fn);
        this.scrutin = cloneDoc(_handle.docSync());
        this.syncFlags();
    },

    syncFlags(){
        const s = this.scrutin;
        this.allVoted = !!s && s.ballots.length >= s.voters.length;
    },

    attach(handle){
        _handle = handle;
        const doc = handle.docSync();
        if(doc){
            this.scrutin = cloneDoc(doc);
            this.syncFlags();
            this.recordScrutin(handle.url, doc.title);
            if(doc.closed) computeTally(doc).then(t => { this.tally = t; });
            this.initStage(doc);
        } else {
            this.stage = 'identify';
        }
        handle.on('change', ev => {
            this.scrutin = cloneDoc(ev.doc);
            this.syncFlags();
            if(ev.doc.closed && !this.tally){
                computeTally(ev.doc).then(t => { this.tally = t; });
            }
        });
    },

    initStage(doc){
        if(doc.mode === 'per-device') this.initStagePerDevice(doc);
        else this.stage = 'identify';
    },
};

Initialisation au chargement

Le store doit être rendu réactif avant qu’on n’attache le doc Automerge. Sinon, les callbacks async posés à l’attache (résolution du tally, listener de changement du doc) capturent le store dans son état non-réactif : leurs écritures ultérieures n’éveillent plus les templates. Le symptôme se voit surtout au reload, quand le tally arrive après le mount et que la winner-card refuse d’apparaître.

L’auto-ouverture pilotée par le brouillon (cf. persistance du formulaire) doit attendre que le mount soit fait : avant le mount, openCreate n’a pas d’app pour déclencher un rendu visible. Et elle ne s’enclenche que sans doc attaché — un brouillon orphelin éclipserait un scrutin en cours.

On monte petite-vue avant d’attendre Automerge : sans ?doc…= dans l’URL, l’empty-state se rend immédiatement et le module Automerge se charge en arrière-plan pendant que l’utilisateur tape ses candidats. Avec ?doc…=, on doit attendre la résolution du handle pour pouvoir attacher — mais on signale loadingDoc pour que l’empty-state ne flashe pas pendant ce temps. Dans tous les cas data-app-ready n’est posé qu’à la fin de l’init : pour les tests, ce signal continue de vouloir dire “prêt à interagir, doc attaché si demandé”.

const reactiveStore = reactive(voteStore);
window.__voteStore = reactiveStore;
reactiveStore.pastScrutins = loadPastScrutins();

// Hook de test : force un ranking sans passer par le drag-and-drop.
window.testForceRanking = function(order){
    reactiveStore.ui.ranking = order.slice();
};

window.addEventListener('popstate', () => syncOverlaysFromHistory(reactiveStore));

(async function init(){
    const urlParam = new URLSearchParams(location.search).get('doc');
    reactiveStore.loadingDoc = !!urlParam;

    createApp(reactiveStore).mount('body');

    if(urlParam){
        const repo = await ensureRepo(reactiveStore);
        const handle = await loadInitialHandle(repo);
        if(handle) reactiveStore.attach(handle);
        reactiveStore.loadingDoc = false;
    } else {
        // Préchauffe Automerge en tâche de fond pour que le clic
        // sur =Créer= ne paie pas le téléchargement.
        ensureRepo(reactiveStore).catch(() => {});
        if(hasNonEmptyDraft()) reactiveStore.openCreate();
    }

    document.body.setAttribute('data-app-ready', '1');
})();

Bases visuelles

Thème sombre minimal, palette “urne”. Rien d’original — l’UX est dans le flux, pas dans la décoration.

Le bloc partagé pwa-meta() fige le viewport avec user-scalable=no (comportement PWA “app-like” par défaut), ce qui interdit le pinch-zoom ; pour autoriser le zoom sur les photos de candidats (cf. Photos de candidats), le root pose une seconde balise viewport avec maximum-scale=5.0 — la dernière rendue l’emporte.

:root{
    --bg:#1b1d2e; --card:#262a40; --fg:#e8e8f0; --muted:#8a8ea5;
    --accent:#f9a826; --danger:#e94560; --ok:#4ecca3;
}
*{box-sizing:border-box}
[v-cloak]{display:none !important}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif;line-height:1.4;min-height:100vh;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom) 0}
button{font:inherit;cursor:pointer;border:0;border-radius:6px;padding:12px 20px;background:var(--accent);color:#111;font-weight:600}
button.secondary{background:var(--card);color:var(--fg)}
button.cta,button.primary{background:var(--accent);color:#111}
button:disabled{opacity:.4;cursor:not-allowed}
input,textarea{font:inherit;background:var(--card);color:var(--fg);border:1px solid #333;border-radius:6px;padding:10px;width:100%}
label{display:block;margin-bottom:12px;font-size:.9rem;color:var(--muted)}
label input{margin-top:4px}
fieldset{border:1px solid #333;border-radius:6px;padding:10px 12px;margin:12px 0}
legend{color:var(--muted);font-size:.85rem;padding:0 6px}
.row{display:flex;gap:6px;margin-bottom:6px}
.row input{flex:1}
.add-row{background:transparent;color:var(--accent);padding:6px;font-weight:500}
h1,h2,h3{font-family:system-ui,sans-serif}

.modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;padding:16px;z-index:100}
.modal.open{display:flex}
.modal-panel{background:var(--bg);border-radius:12px;padding:20px;max-width:420px;width:100%;max-height:90vh;overflow:auto}
.modal-panel h2{margin-top:0}
.modal-actions{display:flex;gap:10px;margin-top:16px}
.modal-actions button{flex:1}
.create-error{color:var(--danger);font-size:.85rem;margin:8px 0 0 0;text-align:center}
input.is-duplicate{border-color:var(--danger);color:var(--danger)}

Hash de build

PWA oblige, le service worker peut servir une version périmée pendant qu’une nouvelle a été tanglée. Pour repérer la build qui tourne réellement, un petit hash du bloc root est affiché en haut à gauche — sept caractères à comparer au #+RESULTS: du bloc build-hash en tête de document. Le marqueur est en muted avec pointer-events:none : il informe sans gêner les taps.

<span class="build-tag" title="Build">nil</span>

.build-tag{position:fixed;top:calc(env(safe-area-inset-top) + 8px);left:8px;font-size:.65rem;color:var(--muted);font-family:monospace;pointer-events:none;z-index:50}