Files
ai-for-dummies/skills-review/vote.js
T
Marcos Paulo 47fff7d289 revert(motion): drop the legacy-file edits, keep the Astro ones
The motion pass edited five legacy files. Two changes were behaviour
regressions on the live site, and all five were out of scope: the brief
asks for an inventory of `src/`, and the legacy pages are what visitors
see until task 20 cuts over.

The confirmed break: `styles.css` changed the reading-progress bar to
`width:100%; transform:scaleX(0)`, but `app.js:406` still sets
`style.width = "N%"`. The inline width applies, the scaleX(0) is never
updated, and the bar renders at 0px on every legacy page. Verified in a
headless browser at 1100px, scrolled halfway: computed transform
`matrix(0, 0, 0, 1, 0, 0)`, bounding width `0`. The Astro island was
updated to set `transform` and is correct -- its half-scrolled bar
measures 595px -- so only the legacy half was broken.

The route-meter change (`height:var(--score)` to
`transform:scaleY(var(--score))` in `responsive.css`) does work:
`--score` is a percentage and Chromium accepts a percentage in
`scaleY()`. It is reverted anyway, because it belongs in the component.
`responsive.css` is being retired by task 15e, which is porting these
rules into the components that need them; that is where the meter should
stop animating a layout property. Noted in the 15e brief.

The three review-desk animations added to `skills-review/` are not
broken, but they change live-site behaviour ahead of the cutover and the
same motion already exists on the Astro side. Reverted for the same
reason.

Kept: every `src/` change -- the ReadingProgress transform, the guide
panel swap, the change-lens and package-preview easing, and the vote
widget tally pop.
2026-09-05 23:31:18 +00:00

54 lines
3.3 KiB
JavaScript

// Reader vote widget: "which draft would you ship?" per reviewed skill.
// The page itself is static (Gitea Pages), so this talks to a small
// separate API — see /vote-service in the repository root. One vote per
// source is enforced server-side by IP, not here; this module only renders
// state and remembers the local choice so a returning visitor sees it
// without re-voting.
const API_BASE = (window.SKILLS_REVIEW_VOTE_API || '').replace(/\/$/, '');
const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#039;' })[character]);
function voterId() {
let id = localStorage.getItem('skills-review-voter-id');
if (!id) { id = crypto.randomUUID(); localStorage.setItem('skills-review-voter-id', id); }
return id;
}
async function api(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', 'X-Voter-Id': voterId(), ...options.headers } });
if (!response.ok) throw new Error(`vote API ${response.status}`);
return response.json();
}
function widgetMarkup(skillId, tally, you, unavailable) {
const total = (tally.original || 0) + (tally.improved || 0);
const share = (count) => total ? Math.round((count / total) * 100) : 0;
if (unavailable) return `<section class="vote-widget" aria-label="Vote unavailable"><span>READER VOTE</span><p>Voting is offline right now — the vote service is not configured or unreachable.</p></section>`;
return `<section class="vote-widget" aria-label="Vote on this review" data-skill="${escape(skillId)}">
<span>WHICH DRAFT WOULD YOU SHIP?</span>
<div class="vote-buttons" role="group" aria-label="Cast your vote">
<button data-vote="original" aria-pressed="${you === 'original'}">Original<b>${tally.original || 0} · ${share(tally.original || 0)}%</b></button>
<button data-vote="improved" aria-pressed="${you === 'improved'}">Improved draft<b>${tally.improved || 0} · ${share(tally.improved || 0)}%</b></button>
</div>
<p class="vote-note">${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}</p>
</section>`;
}
export async function renderVoteWidget(container, skillId) {
if (!API_BASE) { container.innerHTML = widgetMarkup(skillId, {}, null, true); return; }
container.innerHTML = widgetMarkup(skillId, {}, null, false);
const cast = async (choice) => {
container.innerHTML = widgetMarkup(skillId, {}, null, false);
try {
const result = await api('/api/votes', { method: 'POST', body: JSON.stringify({ skillId, choice }) });
container.innerHTML = widgetMarkup(skillId, { original: result.original, improved: result.improved }, result.you, false);
bind();
} catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); }
};
function bind() { container.querySelectorAll('[data-vote]').forEach((button) => button.addEventListener('click', () => cast(button.dataset.vote))); }
try {
const result = await api(`/api/votes?skillId=${encodeURIComponent(skillId)}`);
container.innerHTML = widgetMarkup(skillId, result.tallies?.[skillId] || {}, result.you, false);
} catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); }
bind();
}