feat: add reader vote widget and vote-service

The skills-review desk is static, so "which draft would you ship?" needs a
stateful counterpart. vote-service is a small Go API on its own pod backed by
a JSON file on a ReadWriteOnce PVC, with one active vote per skill per source
IP as the anti-abuse rule and CORS (ALLOWED_ORIGIN) as the caller boundary.

Deployment notes that differ from the obvious path, all confirmed against the
live cluster: the image is side-loaded with `ctr image import` plus
`imagePullPolicy: Never` because kubelet has no credentials for the Nexus ref;
the pod is pinned to `kubernets` because the hostpath PV takes a nodeAffinity
for whichever node first binds it; and public exposure is Caddy on the VPS,
not the cloudflared tunnel.

The ingress controller runs with `use-forwarded-headers` off, so nginx
overwrites X-Forwarded-For with its own peer — every visitor would collapse
into one voter and each skill would cap at one vote overall. Caddy stamps the
true remote address into X-Client-IP, which nginx forwards untouched, and
clientIP() reads that first. Scoped to this app rather than flipping the
global flag, which would change client-IP handling for every other ingress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-09-04 23:49:55 +00:00
parent d12d301a1a
commit e2bcfff5ab
14 changed files with 714 additions and 8 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
import { catalog } from './catalog.js';
import { files } from './files.js';
import { renderVoteWidget } from './vote.js';
const state = { selected: catalog[0], query: '', preview: 'original', file: null, sourceByPath: new Map(), lens: false, rendered: false, diff: false, searching: false, contentMatches: new Set(), searchTimer: null, searchRequest: 0 };
const $ = (selector) => document.querySelector(selector);
@@ -158,7 +159,8 @@ function previewMarkup(entry, available) {
}
function renderDetail() {
const entry = state.selected; const available = packageFiles(entry);
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div id="vote-widget"></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
renderVoteWidget($('#vote-widget'), entry.id);
$('#detail').querySelectorAll('[data-file]').forEach((button) => button.addEventListener('click', () => { state.file = available.find((item) => item.name === button.dataset.file) || available[0]; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); }));
$('#detail').querySelectorAll('[data-preview]').forEach((button) => button.addEventListener('click', () => { state.preview = button.dataset.preview; state.lens = false; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); }));
$('#detail').querySelectorAll('[data-lens]').forEach((button) => button.addEventListener('click', () => { state.lens = !state.lens; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); }));
+7 -5
View File
@@ -6,8 +6,10 @@
<meta name="description" content="Friendly reviews and improved drafts for submitted Agent Skills." />
<title>Submitted Skills — Review Desk</title>
<!-- Bump all review asset versions together when this interface changes. -->
<link rel="stylesheet" href="styles.css?v=20260904-preview-toolbar" />
<link rel="stylesheet" href="change-lens.css?v=20260904-preview-toolbar" />
<link rel="stylesheet" href="styles.css?v=20260904-vote-widget" />
<link rel="stylesheet" href="change-lens.css?v=20260904-vote-widget" />
<!-- Vote API origin: set after vote-service is deployed (see /vote-service). Empty = widget shows "voting offline". -->
<script>window.SKILLS_REVIEW_VOTE_API = 'https://ai-for-dummies-vote.marcospaulo.dev.br';</script>
</head>
<body>
<main>
@@ -25,7 +27,7 @@
</section>
<section class="method">
<div><p class="eyebrow">How to use this desk</p><h2>Compare.<br><em>Then choose.</em></h2></div>
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want.</li></ol>
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want, then vote for the draft you would ship.</li></ol>
</section>
<section class="catalog" id="catalog">
<aside><p class="eyebrow">The catalog</p><label for="skill-filter">Find a skill</label><input id="skill-filter" type="search" placeholder="author, skill, topic" autocomplete="off"><p class="count" id="count"></p><div id="skill-list" role="listbox" aria-label="Submitted skills"></div></aside>
@@ -36,8 +38,8 @@
<p>The recommendations follow the open Agent Skills format: valid frontmatter for discovery, progressive disclosure for context economy, deterministic scripts for fragile repeated mechanics, and behavioral evaluation rather than a checklist of pretty headings.</p>
<div><a href="https://agentskills.io/specification" target="_blank" rel="noreferrer">Format specification ↗</a><a href="https://agentskills.io/skill-creation/best-practices" target="_blank" rel="noreferrer">Writing practices ↗</a><a href="https://agentskills.io/skill-creation/evaluating-skills" target="_blank" rel="noreferrer">Evaluation loop ↗</a><a href="https://agentskills.io/skill-creation/using-scripts" target="_blank" rel="noreferrer">Scripts guide ↗</a></div>
</section>
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&amp;skill=skill-id&amp;view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>.</footer>
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&amp;skill=skill-id&amp;view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>. Votes call a separate service — see <code>vote-service/</code> — one per visitor, tracked by network source.</footer>
</main>
<script type="module" src="app.js?v=20260904-preview-toolbar"></script>
<script type="module" src="app.js?v=20260904-vote-widget"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
+53
View File
@@ -0,0 +1,53 @@
// 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();
}