Skip to content

Commit 25e53dd

Browse files
stackdumpclaude
andcommitted
Phase B / B5.9: v3 UI surface — toggle, banner, results panel
Three pieces glue the v3 client flow to the existing UI: 1. Create-poll form: a "Maximum privacy (v3)" checkbox (poll-max-privacy) with an inline disclaimer about the creator-key backup risk and the 8-choice limit. createPoll already reads this id (B5.8b) so checking the box opts in. 2. Vote view: a v3-privacy banner mirrors the existing v2-backup banner with copy specific to the homomorphic-tally privacy model. loadPoll picks exactly one of the two banners based on the poll's voteSchemaVersion. 3. Closed-poll handling: v3 polls render "Tallies have been published" instead of the v1/v2 reveal button — the reveal server endpoint already returns 404 for v3 (B5.5) so the button has nowhere to go. Close button labeled "Close & Publish Tallies" for v3 to surface that the action is one-step. 4. Results panel: v3 reads tallies from data.tally.tallies (the embedded HomomorphicTallyArtifact) instead of data.tallies, and renders a clean per-vote-data-free results panel with pointers to /votes and /tally for inspection. The v1/v2 tally-proof refresh path is suppressed for v3 since that produces a different artifact shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bd58b48 commit 25e53dd

2 files changed

Lines changed: 69 additions & 20 deletions

File tree

public/poll.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,19 @@ <h2 style="margin-top:0;">Create a New Poll</h2>
181181
<option value="0">No expiration</option>
182182
</select>
183183
</div>
184+
<div class="form-group">
185+
<label style="display:flex; align-items:flex-start; gap:10px; cursor:pointer; padding:12px; border:1px solid var(--border); border-radius:var(--radius); background:rgba(100,149,237,0.04);">
186+
<input type="checkbox" id="poll-max-privacy" style="margin-top:3px;"/>
187+
<span style="flex:1;">
188+
<strong style="color:var(--text);">Maximum privacy (v3)</strong>
189+
<span style="display:block; font-size:0.8rem; color:var(--text-muted); margin-top:4px; line-height:1.4;">
190+
Per-voter choices are encrypted under a creator-only key and never decrypted individually &mdash; only the aggregate is. Removes the reveal phase entirely.
191+
<span style="display:block; color:#ff8844; margin-top:6px;">Requires a creator key. A backup file will download automatically &mdash; <strong>lose both browser storage and the file and the poll cannot be closed.</strong></span>
192+
<span style="display:block; margin-top:4px;">Limited to <strong>8 choices</strong>.</span>
193+
</span>
194+
</span>
195+
</label>
196+
</div>
184197
<div style="display:flex; gap:12px; margin-top:24px;">
185198
<button class="btn-primary" id="btn-create" onclick="createPoll()">Create Poll</button>
186199
<button class="btn-secondary" onclick="showList()">Cancel</button>
@@ -206,6 +219,9 @@ <h2 class="poll-title" id="vote-title"></h2>
206219
<div id="v2-backup-banner" style="display:none; margin-bottom:16px; padding:10px 14px; border:1px solid #6495ed; border-radius:var(--radius); background:rgba(100,149,237,0.08); font-size:0.8rem; color:#9cb7e8;">
207220
<strong>Coercion-resistant poll (v2):</strong> when you cast your vote, your browser will automatically download a small backup JSON. Keep it safe &mdash; without it, a cleared browser cache means your vote cannot be revealed. The older "re-sign to recover" path has been removed so that a captured wallet signature alone can't deanonymize your vote.
208221
</div>
222+
<div id="v3-privacy-banner" style="display:none; margin-bottom:16px; padding:10px 14px; border:1px solid #6495ed; border-radius:var(--radius); background:rgba(100,149,237,0.08); font-size:0.8rem; color:#9cb7e8;">
223+
<strong>Maximum privacy (v3):</strong> your choice is encrypted in your browser under the creator's public key and only ever decrypted in aggregate. There is no reveal phase &mdash; results appear once the creator publishes the tally. A backup file will download with your vote so you can re-prove if needed; it does not contain your choice.
224+
</div>
209225

210226
<div id="vote-choices"></div>
211227

public/poll.js

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,13 @@ async function loadPoll(pollId) {
360360
regBar.style.display = 'none';
361361
}
362362

363+
const schemaVersion = poll.voteSchemaVersion || 1;
364+
// Schema-version banners — only one is visible at a time.
365+
const v2Banner = document.getElementById('v2-backup-banner');
366+
const v3Banner = document.getElementById('v3-privacy-banner');
367+
if (v2Banner) v2Banner.style.display = (schemaVersion === 2) ? 'block' : 'none';
368+
if (v3Banner) v3Banner.style.display = (schemaVersion === 3) ? 'block' : 'none';
369+
363370
if (poll.status === 'active') {
364371
choicesDiv.innerHTML = poll.choices.map((c, i) => `
365372
<label class="choice-option" data-idx="${i}" onclick="selectChoice(this, ${i})">
@@ -369,12 +376,12 @@ async function loadPoll(pollId) {
369376
`).join('');
370377
btnVote.style.display = '';
371378
btnReveal.style.display = 'none';
372-
// Schema-version banner: explain the backup requirement up front.
373-
const banner = document.getElementById('v2-backup-banner');
374-
if (banner) banner.style.display = ((poll.voteSchemaVersion || 1) >= 2) ? 'block' : 'none';
375379

376-
// Show close button if current wallet is the creator
380+
// Show close button if current wallet is the creator. v3
381+
// close button gets a different label since the action
382+
// publishes tallies in one step.
377383
btnClose.style.display = 'none';
384+
btnClose.textContent = (schemaVersion === 3) ? 'Close & Publish Tallies' : 'Close Poll';
378385
const provider = getWalletProvider();
379386
if (provider && poll.creator) {
380387
provider.request({ method: 'eth_accounts' }).then(accts => {
@@ -383,19 +390,24 @@ async function loadPoll(pollId) {
383390
}
384391
}).catch(() => {});
385392
}
393+
} else if (schemaVersion === 3) {
394+
// v3 closed — there's no reveal phase. The tally artifact
395+
// is already published; the results view renders it.
396+
choicesDiv.innerHTML = '<p style="color:var(--text-muted);">This poll is closed. Tallies have been published &mdash; no per-vote reveal is needed.</p>';
397+
btnVote.style.display = 'none';
398+
btnClose.style.display = 'none';
399+
btnReveal.style.display = 'none';
386400
} else {
387-
// Poll closed — always show the Reveal button. If localStorage
388-
// has the voterSecret we submit it directly; otherwise we fall
389-
// through to a re-sign recovery flow (wallet re-signs the
390-
// bitwrap-vote message; voter picks which choice they cast;
391-
// server verifies mimcHash(secret, choice) == commitment).
401+
// v1/v2 closed — show the Reveal button. If localStorage
402+
// has the voterSecret we submit it directly; otherwise we
403+
// fall through to upload-backup or re-sign recovery.
392404
choicesDiv.innerHTML = '<p style="color:var(--text-muted);">This poll is closed. Reveal your vote to add it to the tally.</p>';
393405
btnVote.style.display = 'none';
394406
btnClose.style.display = 'none';
395407
btnReveal.style.display = '';
396408
if (findRevealData(pollId)) {
397409
btnReveal.textContent = 'Reveal My Vote';
398-
} else if ((poll.voteSchemaVersion || 1) >= 2) {
410+
} else if (schemaVersion >= 2) {
399411
btnReveal.textContent = 'Reveal My Vote (upload backup)';
400412
} else {
401413
btnReveal.textContent = 'Reveal My Vote (re-sign to recover)';
@@ -1231,9 +1243,17 @@ async function loadResults(pollId) {
12311243
return;
12321244
}
12331245

1234-
// Poll is closed — show full results
1235-
const tallies = data.tallies || null;
1236-
const talliedCount = data.talliedCount || 0;
1246+
// Poll is closed — show full results.
1247+
// v3 polls embed the full tally artifact under data.tally; v1/v2
1248+
// expose the per-choice tallies inline via the reveal pipeline.
1249+
const schemaVersion = data.voteSchemaVersion || 1;
1250+
const v3 = (schemaVersion === 3);
1251+
const tallies = v3
1252+
? (data.tally && data.tally.tallies) || null
1253+
: (data.tallies || null);
1254+
const talliedCount = v3
1255+
? (data.tally && data.tally.numBallots) || 0
1256+
: (data.talliedCount || 0);
12371257

12381258
if (tallies && talliedCount > 0) {
12391259
const maxVotes = Math.max(...tallies, 1);
@@ -1272,17 +1292,30 @@ async function loadResults(pollId) {
12721292
}
12731293
document.getElementById('results-total').textContent = statusText;
12741294

1275-
// Nullifiers
1276-
const nullifiers = data.nullifiers || [];
12771295
const nullDiv = document.getElementById('results-nullifiers');
1278-
if (nullifiers.length === 0) {
1279-
nullDiv.textContent = 'No votes yet.';
1296+
if (v3) {
1297+
// v3 polls deliberately don't list nullifiers in the
1298+
// results panel — voters can hit /nullifiers directly to
1299+
// confirm their own ballot was counted, but the standard
1300+
// results view stays clean of per-voter data.
1301+
nullDiv.innerHTML =
1302+
'<div style="color:var(--text-muted); font-style:italic;">Per-vote ciphertexts available at <code>/api/polls/' +
1303+
esc(pollId) + '/votes</code>. The decrypt proof for these tallies is at <code>/api/polls/' +
1304+
esc(pollId) + '/tally</code>.</div>';
12801305
} else {
1281-
nullDiv.innerHTML = nullifiers.map(n => `<div style="padding:2px 0;">${esc(n)}</div>`).join('');
1306+
const nullifiers = data.nullifiers || [];
1307+
if (nullifiers.length === 0) {
1308+
nullDiv.textContent = 'No votes yet.';
1309+
} else {
1310+
nullDiv.innerHTML = nullifiers.map(n => `<div style="padding:2px 0;">${esc(n)}</div>`).join('');
1311+
}
12821312
}
12831313

1284-
// Tally proof section — only meaningful after close.
1285-
await refreshTallyProofUI(pollId, data);
1314+
// Tally proof section — v1/v2 only (v3 produces a different
1315+
// artifact under /tally that isn't compatible with this UI).
1316+
if (!v3) {
1317+
await refreshTallyProofUI(pollId, data);
1318+
}
12861319
} catch (err) {
12871320
showMsg('Failed to load results: ' + err.message, 'error');
12881321
}

0 commit comments

Comments
 (0)