spine-rigc 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ballot.ts ADDED
@@ -0,0 +1,869 @@
1
+ /**
2
+ * The ballot — two to four compiled candidates side by side in Esoteric
3
+ * Software's own web player, and the machine-checkable record of which one a
4
+ * human picked.
5
+ *
6
+ * ⭐ Why this exists. Instruments decide most things and they should: `validate`
7
+ * answers "is this valid Spine", `check` answers "does it match these frames",
8
+ * `diff` answers "how far is it from that reference". What none of them can
9
+ * answer is the residue — a choice with no reference behind it, a pose fit with
10
+ * two local optima that measure the same, a key density that is a matter of
11
+ * taste. Those either stall a run or get settled by the author's guess. This is
12
+ * the deliberate human gate for exactly that residue (issue #151).
13
+ *
14
+ * ## Compile first, vote last
15
+ *
16
+ * A candidate reaches this page only because it already compiled and passed the
17
+ * gate — `--candidate` takes the directory `build --out` wrote, the same input
18
+ * `check`, `bench`, `render` and `preview` take, and `build` writes nothing
19
+ * until every assertion is green. So the human is never asked to read JSON, a
20
+ * diff or a spec: the only thing on the page is playable pixels. Everything a
21
+ * machine could have decided has been decided before the file is written.
22
+ *
23
+ * ## Why the labels are A and B and nothing else
24
+ *
25
+ * A voter who can see that candidate B came out of `experiments/new-idea/` is
26
+ * not comparing pictures any more. The page therefore names the candidates
27
+ * `A`, `B`, `C`, `D` and shows no path, no directory and no file size anywhere
28
+ * on screen. The path→label mapping is not *hidden* — it is in the manifest
29
+ * embedded in the same file, because the record has to be auditable — it is
30
+ * simply never rendered.
31
+ *
32
+ * ## Why the record is hashes and not paths
33
+ *
34
+ * `A` means nothing outside one ballot: the next ballot's `A` is a different
35
+ * rig, and a path means nothing at all once the directory is rebuilt. So every
36
+ * candidate carries a **digest** over its skeleton, its atlas and every page,
37
+ * the ballot id derives from those digests, and the vote that comes back is
38
+ * checked against them. A result whose digests are not this ballot's is refused
39
+ * by name rather than appended to the ledger — see `verifyResult`.
40
+ *
41
+ * ## A tie is an outcome, not a gap
42
+ *
43
+ * The ledger distinguishes three states and only three: a ballot with a winner,
44
+ * a ballot the human looked at and declared a tie, and a ballot nobody opened.
45
+ * The middle one is a **recorded** outcome and it is the one that is easy to
46
+ * lose — an interface that offers "pick one" and nothing else turns "these are
47
+ * indistinguishable" into an unanswered question. `both-unacceptable` is the
48
+ * reason code that earns the whole enumeration: it is the tie that means
49
+ * *propose again*, and it is invisible if ties are not recorded.
50
+ *
51
+ * ## The player is referenced, never vendored
52
+ *
53
+ * Identical to [`preview.ts`](preview.ts) and deliberately so: the `<script>`
54
+ * and `<link>` point at unpkg, nothing Esoteric Software owns is copied into
55
+ * this repository, into the published package or into the generated file, and
56
+ * what the file *does* contain is the user's own art. See
57
+ * [NOTICE.md](../NOTICE.md); the posture there covers this command unchanged.
58
+ */
59
+ import { createHash } from 'node:crypto';
60
+ import {
61
+ ATLAS_KEY,
62
+ backgroundHex,
63
+ dataUri,
64
+ embeddedJson,
65
+ escapeHtml,
66
+ PLAYER_LINE,
67
+ PLAYER_SCRIPT_URL,
68
+ PLAYER_STYLE_URL,
69
+ SKELETON_KEY,
70
+ type PreviewPage,
71
+ } from './preview.ts';
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // the vocabulary — every string a machine matches on, in one place
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** The `spec` field of a ballot manifest. */
78
+ export const BALLOT_SPEC = 'rigc-ballot/1';
79
+ /** The `spec` field of a vote result and of every ledger line. */
80
+ export const VOTE_SPEC = 'rigc-vote/1';
81
+ /** Domain separator for a candidate digest, so the hash says what it is a hash of. */
82
+ export const CANDIDATE_SPEC = 'rigc-candidate/1';
83
+
84
+ /** The `choice` that means "reviewed, and neither one wins". */
85
+ export const TIE = 'tie';
86
+
87
+ /**
88
+ * The neutral names, in ballot order.
89
+ *
90
+ * Four is the ceiling because the page has to fit them side by side on one
91
+ * screen — a comparison that needs scrolling is not a comparison — and two is
92
+ * the floor because one candidate is not a vote, it is `rigc preview`.
93
+ */
94
+ export const BALLOT_LABELS = ['A', 'B', 'C', 'D'] as const;
95
+ export const MIN_CANDIDATES = 2;
96
+ export const MAX_CANDIDATES = BALLOT_LABELS.length;
97
+
98
+ /**
99
+ * Why a candidate won. Two codes, and the distinction is the actionable one:
100
+ * `preferred` says the winner is better, `defect-in-others` says the rest are
101
+ * broken — the second is a signal to look at what they share.
102
+ */
103
+ export const WINNER_REASON_CODES = ['preferred', 'defect-in-others'] as const;
104
+
105
+ /**
106
+ * Why nobody won. Four codes, and they are not synonyms:
107
+ * `indistinguishable` — the voter could not see a difference at all;
108
+ * `both-acceptable` — differences were visible and neither is better;
109
+ * `both-unacceptable` — differences were visible and neither is good enough,
110
+ * which is the one that means *propose again* rather than *adopt either*;
111
+ * `unsure` — the voter saw the difference and declined to judge it.
112
+ */
113
+ export const TIE_REASON_CODES = ['indistinguishable', 'both-acceptable', 'both-unacceptable', 'unsure'] as const;
114
+
115
+ export type WinnerReasonCode = (typeof WINNER_REASON_CODES)[number];
116
+ export type TieReasonCode = (typeof TIE_REASON_CODES)[number];
117
+ export type ReasonCode = WinnerReasonCode | TieReasonCode;
118
+
119
+ /** What each code means, for the page's picker and for the docs. One sentence each. */
120
+ export const REASON_CODE_MEANINGS: Record<ReasonCode, string> = {
121
+ preferred: 'this one simply looks better',
122
+ 'defect-in-others': 'the others have a visible defect',
123
+ indistinguishable: 'tie — I could not see a difference',
124
+ 'both-acceptable': 'tie — different, and either would do',
125
+ 'both-unacceptable': 'tie — different, and neither is good enough (propose again)',
126
+ unsure: 'tie — I can see the difference and cannot judge it',
127
+ };
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // hashing — what identifies a candidate
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** `sha256:<64 hex>`. Prefixed so the algorithm travels with the value. */
134
+ export function sha256(body: string | Uint8Array): string {
135
+ const buffer = typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.from(body);
136
+ return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
137
+ }
138
+
139
+ /**
140
+ * One hash over everything a candidate is made of.
141
+ *
142
+ * Pages are folded in **sorted by name** rather than in atlas order, so the
143
+ * digest is a property of the artifact and not of the order this process
144
+ * happened to read it in. The skeleton and the atlas are not sorted — there is
145
+ * one of each, and their roles are fixed by position.
146
+ *
147
+ * ⚠️ The paths are not in it, on purpose. Two builds of the same rig into two
148
+ * directories are the same candidate, and a directory renamed between the
149
+ * ballot and the vote must not invalidate the vote.
150
+ */
151
+ export function candidateDigest(skeletonText: string, atlasText: string, pages: PreviewPage[]): string {
152
+ const lines = [CANDIDATE_SPEC, sha256(skeletonText), sha256(atlasText)];
153
+ for (const page of [...pages].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
154
+ lines.push(`${page.name} ${sha256(page.bytes)}`);
155
+ }
156
+ return sha256(`${lines.join('\n')}\n`);
157
+ }
158
+
159
+ /**
160
+ * The ballot's identity: a hash over the animation and the candidate digests
161
+ * **in ballot order**, truncated to 16 hex characters.
162
+ *
163
+ * Order-sensitive, and that is the useful behaviour rather than an accident. A
164
+ * second ballot over the same two candidates with the sides swapped is a
165
+ * genuinely different question — it is how a run controls for the voter's bias
166
+ * toward the left pane — so it gets its own id and its own ledger line instead
167
+ * of colliding with the first as a duplicate.
168
+ *
169
+ * Truncated because this is a name a human copies into a filename, and 64 bits
170
+ * of a content hash is far more than enough to keep one project's ballots
171
+ * apart. Nothing security-bearing rests on it: the digests, not the id, are
172
+ * what `verifyResult` checks a result against.
173
+ */
174
+ export function ballotId(animation: string | null, digests: string[]): string {
175
+ const body = `${BALLOT_SPEC}\n${animation ?? ''}\n${digests.join('\n')}\n`;
176
+ return sha256(body).slice('sha256:'.length, 'sha256:'.length + 16);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // the manifest — what the page carries about itself
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** One compiled candidate, as the caller hands it over. */
184
+ export interface BallotCandidateInput {
185
+ /**
186
+ * Where it came from. Recorded in the manifest so the mapping is auditable,
187
+ * and **never rendered into the page** — see the header comment.
188
+ */
189
+ source: string;
190
+ skeletonText: string;
191
+ atlasText: string;
192
+ pages: PreviewPage[];
193
+ }
194
+
195
+ export interface ManifestCandidate {
196
+ /** `A`, `B`, `C` or `D` — the only name the page shows. */
197
+ label: string;
198
+ /** The one value that identifies this candidate anywhere. */
199
+ digest: string;
200
+ skeleton: string;
201
+ atlas: string;
202
+ pages: { name: string; sha256: string }[];
203
+ /** The path→label mapping. In the file, never on the screen. */
204
+ source: string;
205
+ }
206
+
207
+ export interface BallotManifest {
208
+ spec: string;
209
+ ballot: string;
210
+ rigc: string;
211
+ /** The one animation every candidate plays, or `null` for setup poses. */
212
+ animation: string | null;
213
+ candidates: ManifestCandidate[];
214
+ }
215
+
216
+ /** The id the embedded manifest is found under, in the page and in the parser. */
217
+ export const MANIFEST_ELEMENT_ID = 'rigc-ballot-manifest';
218
+
219
+ /** What a saved vote should be called, so the page and the CLI say the same name. */
220
+ export function resultFilename(ballot: string): string {
221
+ return `vote-${ballot}.json`;
222
+ }
223
+
224
+ export interface BallotInput {
225
+ candidates: BallotCandidateInput[];
226
+ /** The animation every candidate plays, or `null` when none of them has one. */
227
+ animation: string | null;
228
+ /** rigc's own version, for the generated-by line and the ledger. */
229
+ version: string;
230
+ }
231
+
232
+ /** Refused before anything is written — the caller's arguments, not the art. */
233
+ export class BallotError extends Error {}
234
+
235
+ /** The manifest for a set of candidates, digests and id included. */
236
+ export function ballotManifest(input: BallotInput): BallotManifest {
237
+ if (input.candidates.length < MIN_CANDIDATES || input.candidates.length > MAX_CANDIDATES) {
238
+ throw new BallotError(
239
+ `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} candidates and this one has ${input.candidates.length}` +
240
+ (input.candidates.length < MIN_CANDIDATES ? ' — one candidate on its own is `rigc preview`' : ''),
241
+ );
242
+ }
243
+ const candidates: ManifestCandidate[] = input.candidates.map((candidate, i) => ({
244
+ label: BALLOT_LABELS[i],
245
+ digest: candidateDigest(candidate.skeletonText, candidate.atlasText, candidate.pages),
246
+ skeleton: sha256(candidate.skeletonText),
247
+ atlas: sha256(candidate.atlasText),
248
+ pages: candidate.pages.map((page) => ({ name: page.name, sha256: sha256(page.bytes) })),
249
+ source: candidate.source,
250
+ }));
251
+ return {
252
+ spec: BALLOT_SPEC,
253
+ ballot: ballotId(
254
+ input.animation,
255
+ candidates.map((c) => c.digest),
256
+ ),
257
+ rigc: input.version,
258
+ animation: input.animation,
259
+ candidates,
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Read a manifest back out of a generated page.
265
+ *
266
+ * The ballot file is its own record — there is no sidecar to lose and no
267
+ * database to be out of step with it — so `--record` reads the question out of
268
+ * the same file the human answered.
269
+ */
270
+ export function readBallotManifest(html: string, path: string): BallotManifest {
271
+ const found = new RegExp(
272
+ `<script type="application/json" id="${MANIFEST_ELEMENT_ID}">([\\s\\S]*?)</script>`,
273
+ ).exec(html);
274
+ if (!found) {
275
+ throw new BallotError(
276
+ `${path} carries no <script id="${MANIFEST_ELEMENT_ID}"> — it is not a ballot written by \`rigc vote\``,
277
+ );
278
+ }
279
+ let parsed: unknown;
280
+ try {
281
+ parsed = JSON.parse(found[1]);
282
+ } catch (err) {
283
+ throw new BallotError(`${path}: its embedded manifest is not JSON — ${(err as Error).message}`);
284
+ }
285
+ const manifest = parsed as BallotManifest;
286
+ if (typeof manifest !== 'object' || manifest === null || manifest.spec !== BALLOT_SPEC) {
287
+ throw new BallotError(
288
+ `${path}: its embedded manifest says spec ${JSON.stringify((manifest as { spec?: unknown })?.spec)}, not ${JSON.stringify(BALLOT_SPEC)}`,
289
+ );
290
+ }
291
+ if (!Array.isArray(manifest.candidates) || manifest.candidates.length < MIN_CANDIDATES) {
292
+ throw new BallotError(`${path}: its embedded manifest lists no candidate pair to vote between`);
293
+ }
294
+ return manifest;
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // the result, the refusals, and the ledger
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** What the page hands the human to save. Written by the browser, read here. */
302
+ export interface VoteResult {
303
+ spec: string;
304
+ ballot: string;
305
+ animation: string | null;
306
+ /** label → digest, exactly as the ballot listed them. */
307
+ candidates: { label: string; digest: string }[];
308
+ /** A label, or `tie`. */
309
+ choice: string;
310
+ reasonCode: string;
311
+ /** Free text. May be empty; never absent. */
312
+ reason: string;
313
+ /** The voter's clock, ISO 8601. Provenance, never an input to a check. */
314
+ at: string;
315
+ /** Which Spine Web Player line drew the pixels that were judged. */
316
+ player: string;
317
+ }
318
+
319
+ /**
320
+ * One line of `votes.jsonl`.
321
+ *
322
+ * Key order is the declaration order and `JSON.stringify` preserves it, which
323
+ * is what makes two ledgers diffable line by line.
324
+ */
325
+ export interface LedgerLine {
326
+ spec: string;
327
+ /** 1-based, and equal to the line number. An append-only file's own check. */
328
+ seq: number;
329
+ ballot: string;
330
+ at: string;
331
+ animation: string | null;
332
+ choice: string;
333
+ /** The winning candidate's digest, or `null` for a tie. Labels are ballot-local; this is not. */
334
+ winner: string | null;
335
+ reasonCode: string;
336
+ reason: string;
337
+ /** Which candidates this vote compared — the reviewed set, as digests. */
338
+ coverage: { label: string; digest: string }[];
339
+ /** 1, or n>1 for a re-vote recorded with `--again`. */
340
+ attempt: number;
341
+ rigc: string;
342
+ }
343
+
344
+ /** A named refusal, in the shape the validator's failures already print in. */
345
+ export interface VoteRefusal {
346
+ rule: string;
347
+ detail: string;
348
+ }
349
+
350
+ /** Every rule `verifyResult` can refuse on, so callers can name them without matching prose. */
351
+ export const VOTE_RULES = [
352
+ 'V00_RESULT_IS_A_RIGC_VOTE',
353
+ 'V01_RESULT_NAMES_THIS_BALLOT',
354
+ 'V02_CANDIDATE_DIGESTS_ARE_THE_BALLOTS',
355
+ 'V03_BALLOT_ID_DERIVES_FROM_ITS_CANDIDATES',
356
+ 'V04_CHOICE_IS_ON_THE_BALLOT',
357
+ 'V05_REASON_CODE_FITS_THE_CHOICE',
358
+ 'V06_NOT_ALREADY_RECORDED',
359
+ ] as const;
360
+
361
+ function asRecord(value: unknown): Record<string, unknown> | null {
362
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
363
+ }
364
+
365
+ /**
366
+ * Check a result against the ballot it claims to answer.
367
+ *
368
+ * Every refusal is named, because the caller of this is an agent and "invalid
369
+ * vote" is not something an agent can act on. The rules are ordered so the
370
+ * earliest failure is the most fundamental one, and they **all** run: a result
371
+ * with three things wrong with it says so in one pass rather than over three.
372
+ */
373
+ export function verifyResult(
374
+ manifest: BallotManifest,
375
+ raw: unknown,
376
+ recorded: { attempts: number; again: boolean },
377
+ ): { refusals: VoteRefusal[]; line: LedgerLine | null } {
378
+ const refusals: VoteRefusal[] = [];
379
+ const refuse = (rule: string, detail: string): void => {
380
+ refusals.push({ rule, detail });
381
+ };
382
+
383
+ const result = asRecord(raw);
384
+ const shaped =
385
+ result !== null &&
386
+ result.spec === VOTE_SPEC &&
387
+ typeof result.ballot === 'string' &&
388
+ typeof result.choice === 'string' &&
389
+ typeof result.reasonCode === 'string' &&
390
+ typeof result.reason === 'string' &&
391
+ typeof result.at === 'string' &&
392
+ Array.isArray(result.candidates);
393
+ if (!shaped) {
394
+ refuse(
395
+ 'V00_RESULT_IS_A_RIGC_VOTE',
396
+ result === null
397
+ ? 'the file is not a JSON object'
398
+ : `spec=${JSON.stringify(result.spec)} (want ${JSON.stringify(VOTE_SPEC)}), and it needs string ` +
399
+ '"ballot", "choice", "reasonCode", "reason", "at" plus an array "candidates"',
400
+ );
401
+ // Nothing below can read a shape that is not there, and guessing at the
402
+ // missing halves would report failures about fields the file never had.
403
+ return { refusals, line: null };
404
+ }
405
+ const vote = raw as VoteResult;
406
+
407
+ if (vote.ballot !== manifest.ballot) {
408
+ refuse(
409
+ 'V01_RESULT_NAMES_THIS_BALLOT',
410
+ `the result answers ballot ${JSON.stringify(vote.ballot)} and this ballot is ${JSON.stringify(manifest.ballot)} — ` +
411
+ 'point --ballot at the file this vote came from',
412
+ );
413
+ }
414
+
415
+ const want = manifest.candidates.map((c) => `${c.label} ${c.digest}`);
416
+ const got = vote.candidates.map((c) => `${c?.label} ${c?.digest}`);
417
+ if (want.length !== got.length || want.some((entry, i) => entry !== got[i])) {
418
+ const first = want.findIndex((entry, i) => entry !== got[i]);
419
+ refuse(
420
+ 'V02_CANDIDATE_DIGESTS_ARE_THE_BALLOTS',
421
+ want.length !== got.length
422
+ ? `the result lists ${got.length} candidate(s) and the ballot has ${want.length}`
423
+ : `candidate ${first + 1} of ${want.length}: the result says ${JSON.stringify(got[first])} and the ballot says ` +
424
+ `${JSON.stringify(want[first])} — a vote is only about the pixels whose hashes it carries`,
425
+ );
426
+ }
427
+
428
+ const derived = ballotId(
429
+ manifest.animation,
430
+ manifest.candidates.map((c) => c.digest),
431
+ );
432
+ if (derived !== manifest.ballot) {
433
+ refuse(
434
+ 'V03_BALLOT_ID_DERIVES_FROM_ITS_CANDIDATES',
435
+ `the ballot calls itself ${JSON.stringify(manifest.ballot)} but its own candidate digests hash to ` +
436
+ `${JSON.stringify(derived)} — the ballot file has been edited since it was written`,
437
+ );
438
+ }
439
+
440
+ const labels = manifest.candidates.map((c) => c.label);
441
+ const isTie = vote.choice === TIE;
442
+ const winner = manifest.candidates.find((c) => c.label === vote.choice) ?? null;
443
+ if (!isTie && winner === null) {
444
+ refuse(
445
+ 'V04_CHOICE_IS_ON_THE_BALLOT',
446
+ `choice ${JSON.stringify(vote.choice)} is neither ${JSON.stringify(TIE)} nor one of [${labels.join(', ')}]`,
447
+ );
448
+ }
449
+
450
+ const allowed: readonly string[] = isTie ? TIE_REASON_CODES : WINNER_REASON_CODES;
451
+ if (!allowed.includes(vote.reasonCode)) {
452
+ refuse(
453
+ 'V05_REASON_CODE_FITS_THE_CHOICE',
454
+ `choice ${JSON.stringify(vote.choice)} takes a reason code from [${allowed.join(', ')}], and the result says ` +
455
+ `${JSON.stringify(vote.reasonCode)}`,
456
+ );
457
+ }
458
+
459
+ if (recorded.attempts > 0 && !recorded.again) {
460
+ refuse(
461
+ 'V06_NOT_ALREADY_RECORDED',
462
+ `ballot ${manifest.ballot} is already in the ledger ${recorded.attempts} time(s) — pass --again to record ` +
463
+ 'a deliberate re-vote, or point --ledger at a different file',
464
+ );
465
+ }
466
+
467
+ if (refusals.length > 0) return { refusals, line: null };
468
+ return {
469
+ refusals,
470
+ line: {
471
+ spec: VOTE_SPEC,
472
+ seq: 0, // the caller sets this from the ledger it is appending to
473
+ ballot: manifest.ballot,
474
+ at: vote.at,
475
+ animation: manifest.animation,
476
+ choice: vote.choice,
477
+ winner: winner === null ? null : winner.digest,
478
+ reasonCode: vote.reasonCode,
479
+ reason: vote.reason,
480
+ coverage: manifest.candidates.map((c) => ({ label: c.label, digest: c.digest })),
481
+ attempt: recorded.attempts + 1,
482
+ rigc: manifest.rigc,
483
+ },
484
+ };
485
+ }
486
+
487
+ /** One ledger line as the text that goes on disk, newline included. */
488
+ export function ledgerLineText(line: LedgerLine): string {
489
+ return `${JSON.stringify(line)}\n`;
490
+ }
491
+
492
+ /**
493
+ * Read a ledger back.
494
+ *
495
+ * A line that is not a vote is an error rather than a line to skip: a ledger
496
+ * whose reader quietly drops what it does not understand cannot be the record
497
+ * of anything, and the coverage figure computed over it would be wrong in the
498
+ * safe-looking direction.
499
+ */
500
+ export function parseLedger(text: string, path: string): LedgerLine[] {
501
+ const lines: LedgerLine[] = [];
502
+ const raw = text.split('\n');
503
+ for (let i = 0; i < raw.length; i++) {
504
+ if (raw[i].trim() === '') continue;
505
+ let parsed: unknown;
506
+ try {
507
+ parsed = JSON.parse(raw[i]);
508
+ } catch (err) {
509
+ throw new BallotError(`${path}:${i + 1} is not JSON — ${(err as Error).message}`);
510
+ }
511
+ const record = asRecord(parsed);
512
+ if (record === null || record.spec !== VOTE_SPEC || typeof record.ballot !== 'string') {
513
+ throw new BallotError(`${path}:${i + 1} is not a ${VOTE_SPEC} line`);
514
+ }
515
+ lines.push(parsed as LedgerLine);
516
+ }
517
+ return lines;
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // the page
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /**
525
+ * One player's config, in the shape `preview.ts` established.
526
+ *
527
+ * ⚠️ Every candidate keys its own skeleton and atlas under the SAME names, and
528
+ * that is safe rather than lucky: `SpinePlayer` builds one `AssetManager` and
529
+ * one `Downloader` per instance, and `setRawDataURI` writes into that
530
+ * instance's own map. Renaming the keys per candidate would work too, but it
531
+ * would move each atlas page's lookup under a prefix — the player resolves a
532
+ * page as `dirname(config.atlas) + pageName` — and the value of embedding the
533
+ * atlas text verbatim is that the file which plays is the file that was built.
534
+ */
535
+ function playerConfig(candidate: BallotCandidateInput, animation: string | null): Record<string, unknown> {
536
+ const rawDataURIs: Record<string, string> = {
537
+ [SKELETON_KEY]: dataUri('application/json', candidate.skeletonText),
538
+ [ATLAS_KEY]: dataUri('text/plain', candidate.atlasText),
539
+ };
540
+ for (const page of candidate.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
541
+ const config: Record<string, unknown> = {
542
+ skeleton: SKELETON_KEY,
543
+ atlas: ATLAS_KEY,
544
+ rawDataURIs,
545
+ showControls: true,
546
+ alpha: false,
547
+ backgroundColor: backgroundHex(),
548
+ };
549
+ // Present only when there is one, exactly as in `preview.ts`: the player
550
+ // checks for the key rather than for a null, and `config.animation` is what
551
+ // makes it autoplay looping.
552
+ if (animation !== null) {
553
+ config.animation = animation;
554
+ config.animations = [animation];
555
+ }
556
+ return config;
557
+ }
558
+
559
+ /**
560
+ * The whole ballot: the page, and the manifest that is embedded in it.
561
+ *
562
+ * Both, from one call, because the caller needs both and the digests are a hash
563
+ * over every page's bytes — computing them twice is the kind of duplication
564
+ * that eventually computes two different answers. There is no template file and
565
+ * no build step, the same reasoning as `buildPreview`: the published package
566
+ * carries this the way it carries every other module.
567
+ */
568
+ export function buildBallot(input: BallotInput): { html: string; manifest: BallotManifest } {
569
+ const manifest = ballotManifest(input);
570
+ const configs = input.candidates.map((candidate) => playerConfig(candidate, input.animation));
571
+ const labels = manifest.candidates.map((c) => c.label);
572
+ const filename = resultFilename(manifest.ballot);
573
+ const playing =
574
+ input.animation === null
575
+ ? 'the setup pose — no animation'
576
+ : `<code>${escapeHtml(input.animation)}</code>, looping`;
577
+
578
+ // What the page hands to the browser: the labels, the digests and the
579
+ // animation. `source` is deliberately NOT in here — it lives in the manifest
580
+ // element, which the page never reads, so no code path can put a path on the
581
+ // screen by accident.
582
+ const ballotData = {
583
+ spec: VOTE_SPEC,
584
+ ballot: manifest.ballot,
585
+ animation: manifest.animation,
586
+ candidates: manifest.candidates.map((c) => ({ label: c.label, digest: c.digest })),
587
+ player: PLAYER_LINE,
588
+ filename,
589
+ reasonCodes: {
590
+ winner: WINNER_REASON_CODES.map((code) => ({ code, meaning: REASON_CODE_MEANINGS[code] })),
591
+ tie: TIE_REASON_CODES.map((code) => ({ code, meaning: REASON_CODE_MEANINGS[code] })),
592
+ },
593
+ };
594
+
595
+ const panes = manifest.candidates
596
+ .map(
597
+ (c) => `<section class="pane">
598
+ <h2>${c.label}</h2>
599
+ <div class="stage" id="rigc-player-${c.label}"></div>
600
+ </section>`,
601
+ )
602
+ .join('\n');
603
+
604
+ const choices = [...labels, TIE]
605
+ .map(
606
+ (value) =>
607
+ `<button type="button" class="choice" aria-pressed="false" data-choice="${escapeHtml(value)}">${
608
+ value === TIE ? 'tie / no preference' : escapeHtml(value)
609
+ }</button>`,
610
+ )
611
+ .join('\n ');
612
+
613
+ const html = `<!doctype html>
614
+ <html lang="en">
615
+ <head>
616
+ <meta charset="utf-8">
617
+ <meta name="viewport" content="width=device-width, initial-scale=1">
618
+ <title>rigc ballot — ${escapeHtml(manifest.ballot)}</title>
619
+ <!--
620
+ Generated by rigc ${escapeHtml(input.version)} — https://github.com/firejune/rigc
621
+
622
+ Every candidate's skeleton, atlas and atlas pages are embedded in this file as
623
+ data URIs, so it plays on its own with no server and no sibling files.
624
+
625
+ It plays them in the Spine Web Player, which is NOT embedded: the script and
626
+ stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
627
+ 2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
628
+ License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
629
+ requires each user of a product integrating them to hold a Spine Editor
630
+ license. Nothing owned by Esoteric Software is redistributed by rigc.
631
+
632
+ The candidate paths are in the manifest element below, not on the screen: a
633
+ voter who can see where a candidate came from is no longer comparing pictures.
634
+ -->
635
+ <link rel="stylesheet" href="${PLAYER_STYLE_URL}">
636
+ <style>
637
+ :root { color-scheme: light dark; }
638
+ html, body { margin: 0; min-height: 100%; }
639
+ body {
640
+ background: ${backgroundHex()};
641
+ color: #1a1a1a;
642
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
643
+ }
644
+ header, footer { padding: 10px 14px; }
645
+ header { border-bottom: 1px solid rgba(0, 0, 0, 0.15); display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
646
+ header b { font-weight: 600; }
647
+ header span { opacity: 0.65; }
648
+ header button { margin-left: auto; }
649
+ button { font: inherit; padding: 4px 12px; border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; background: rgba(255, 255, 255, 0.6); cursor: pointer; }
650
+ button:hover { background: rgba(255, 255, 255, 0.95); }
651
+ button[aria-pressed="true"] { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
652
+ #panes { display: grid; grid-template-columns: repeat(${manifest.candidates.length}, minmax(0, 1fr)); gap: 1px; background: rgba(0, 0, 0, 0.15); }
653
+ @media (max-width: 720px) { #panes { grid-template-columns: minmax(0, 1fr); } }
654
+ .pane { background: ${backgroundHex()}; display: flex; flex-direction: column; min-width: 0; }
655
+ .pane h2 { margin: 0; padding: 6px 14px; font-size: 15px; letter-spacing: 0.12em; }
656
+ .stage { height: 52vh; min-height: 260px; }
657
+ #vote { border-top: 1px solid rgba(0, 0, 0, 0.15); display: flex; flex-direction: column; gap: 10px; }
658
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
659
+ .row > label, .caption { opacity: 0.65; }
660
+ select, input[type="text"], textarea { font: inherit; padding: 4px 6px; border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; background: rgba(255, 255, 255, 0.6); }
661
+ input[type="text"] { flex: 1 1 320px; min-width: 0; }
662
+ textarea { width: 100%; box-sizing: border-box; min-height: 150px; white-space: pre; overflow-x: auto; }
663
+ #egress[hidden] { display: none; }
664
+ #rigc-status { margin: 0; padding: 10px 14px; border-top: 1px solid rgba(0, 0, 0, 0.15); white-space: pre-wrap; }
665
+ #rigc-status[data-state="error"] { background: #7d1d1d; color: #fff; }
666
+ code { background: rgba(0, 0, 0, 0.07); padding: 0 3px; border-radius: 3px; }
667
+ </style>
668
+ </head>
669
+ <body>
670
+ <script type="application/json" id="${MANIFEST_ELEMENT_ID}">${embeddedJson(manifest)}</script>
671
+ <header>
672
+ <b>ballot ${escapeHtml(manifest.ballot)}</b>
673
+ <span>${manifest.candidates.length} candidates — ${playing}. Watch both, then pick one or call it a tie.</span>
674
+ <button type="button" id="restart"${input.animation === null ? ' disabled' : ''}>restart all</button>
675
+ </header>
676
+ <div id="panes">
677
+ ${panes}
678
+ </div>
679
+ <footer id="vote">
680
+ <div class="row" role="group" aria-label="winner">
681
+ <span class="caption">winner</span>
682
+ ${choices}
683
+ </div>
684
+ <div class="row">
685
+ <label for="reason-code">because</label>
686
+ <select id="reason-code"></select>
687
+ <input type="text" id="reason" placeholder="optional — what you saw, in your own words">
688
+ </div>
689
+ <div id="egress" hidden>
690
+ <p>Save this as <b><code id="filename"></code></b>, then hand it back:
691
+ <code id="record-command"></code></p>
692
+ <div class="row">
693
+ <button type="button" id="copy">copy to clipboard</button>
694
+ <a id="download" download>download</a>
695
+ <span id="copy-said"></span>
696
+ </div>
697
+ <textarea id="json" readonly spellcheck="false"></textarea>
698
+ </div>
699
+ </footer>
700
+ <p id="rigc-status">loading the Spine Web Player…</p>
701
+ <script src="${PLAYER_SCRIPT_URL}"></script>
702
+ <script>
703
+ (function () {
704
+ var data = ${embeddedJson(ballotData)};
705
+ var configs = ${embeddedJson(configs)};
706
+ var status = document.getElementById('rigc-status');
707
+ var state = {
708
+ status: 'loading',
709
+ message: null,
710
+ ballot: data.ballot,
711
+ players: {},
712
+ ready: [],
713
+ failed: [],
714
+ choice: null,
715
+ at: null,
716
+ result: null
717
+ };
718
+ window.rigcBallot = state;
719
+
720
+ function say(kind, message) {
721
+ state.status = kind;
722
+ state.message = message;
723
+ status.textContent = message;
724
+ status.setAttribute('data-state', kind);
725
+ }
726
+
727
+ // ---- the players ---------------------------------------------------------
728
+ if (typeof window.spine === 'undefined' || typeof window.spine.SpinePlayer !== 'function') {
729
+ say('error', 'The Spine Web Player did not load from ${PLAYER_SCRIPT_URL} — this page needs a network connection the first time it is opened.');
730
+ } else {
731
+ for (var i = 0; i < data.candidates.length; i++) {
732
+ (function (label, config) {
733
+ config.success = function (player) {
734
+ state.players[label] = player;
735
+ if (state.ready.indexOf(label) === -1) state.ready.push(label);
736
+ if (state.failed.length === 0 && state.ready.length === data.candidates.length) {
737
+ say('ready', state.ready.join(' and ') + ' are playing in Spine Web Player ${PLAYER_LINE} — every pixel they draw is embedded in this file.');
738
+ }
739
+ };
740
+ config.error = function (player, message) {
741
+ state.failed.push(label + ': ' + String(message));
742
+ say('error', state.failed.join('\\n'));
743
+ };
744
+ try {
745
+ new window.spine.SpinePlayer('rigc-player-' + label, config);
746
+ } catch (err) {
747
+ state.failed.push(label + ': ' + String(err && err.message ? err.message : err));
748
+ say('error', state.failed.join('\\n'));
749
+ }
750
+ })(data.candidates[i].label, configs[i]);
751
+ }
752
+ }
753
+
754
+ // One button, every pane: comparing spacing means seeing the same instant of
755
+ // two animations, and two players started seconds apart never show it.
756
+ document.getElementById('restart').addEventListener('click', function () {
757
+ if (data.animation === null) return;
758
+ for (var label in state.players) {
759
+ var player = state.players[label];
760
+ try {
761
+ if (player.paused) player.play();
762
+ player.setAnimation(data.animation, true);
763
+ } catch (err) {
764
+ say('error', label + ': ' + String(err && err.message ? err.message : err));
765
+ }
766
+ }
767
+ });
768
+
769
+ // ---- the vote ------------------------------------------------------------
770
+ var reasonSelect = document.getElementById('reason-code');
771
+ var reasonText = document.getElementById('reason');
772
+ var egress = document.getElementById('egress');
773
+ var jsonBox = document.getElementById('json');
774
+ var buttons = [].slice.call(document.querySelectorAll('.choice'));
775
+
776
+ function fillReasonCodes(choice) {
777
+ var codes = choice === ${JSON.stringify(TIE)} ? data.reasonCodes.tie : data.reasonCodes.winner;
778
+ reasonSelect.textContent = '';
779
+ for (var i = 0; i < codes.length; i++) {
780
+ var option = document.createElement('option');
781
+ option.value = codes[i].code;
782
+ option.textContent = codes[i].code + ' — ' + codes[i].meaning;
783
+ reasonSelect.appendChild(option);
784
+ }
785
+ }
786
+
787
+ // Written key by key rather than left to JSON.stringify's argument order so
788
+ // the shape is the one \`rigc vote --record\` documents, and two votes on one
789
+ // ballot differ only where they disagree.
790
+ //
791
+ // \`at\` is stamped when the CHOICE is made and then held still, so editing the
792
+ // wording of a reason for a minute does not keep moving the moment the vote
793
+ // was cast.
794
+ function resultJson() {
795
+ return JSON.stringify({
796
+ spec: data.spec,
797
+ ballot: data.ballot,
798
+ animation: data.animation,
799
+ candidates: data.candidates,
800
+ choice: state.choice,
801
+ reasonCode: reasonSelect.value,
802
+ reason: reasonText.value,
803
+ at: state.at,
804
+ player: data.player
805
+ }, null, 2) + '\\n';
806
+ }
807
+
808
+ var downloadUrl = null;
809
+ function refresh() {
810
+ if (state.choice === null) return;
811
+ var text = resultJson();
812
+ state.result = text;
813
+ jsonBox.value = text;
814
+ egress.hidden = false;
815
+ document.getElementById('filename').textContent = data.filename;
816
+ document.getElementById('record-command').textContent =
817
+ 'rigc vote --record ' + data.filename + ' --ballot <this file>';
818
+ var link = document.getElementById('download');
819
+ if (downloadUrl !== null) URL.revokeObjectURL(downloadUrl);
820
+ downloadUrl = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
821
+ link.href = downloadUrl;
822
+ link.setAttribute('download', data.filename);
823
+ }
824
+
825
+ buttons.forEach(function (button) {
826
+ button.addEventListener('click', function () {
827
+ var choice = button.getAttribute('data-choice');
828
+ var changed = choice !== state.choice;
829
+ state.choice = choice;
830
+ if (changed || state.at === null) state.at = new Date().toISOString();
831
+ buttons.forEach(function (other) {
832
+ other.setAttribute('aria-pressed', String(other === button));
833
+ });
834
+ if (changed) fillReasonCodes(choice);
835
+ refresh();
836
+ });
837
+ });
838
+ reasonSelect.addEventListener('change', refresh);
839
+ reasonText.addEventListener('input', refresh);
840
+
841
+ // \`navigator.clipboard\` is undefined on a file:// page in Chromium — it is not
842
+ // a secure context — so the button falls back to selecting the textarea, and
843
+ // the textarea is there in the first place so a manual copy always works.
844
+ document.getElementById('copy').addEventListener('click', function () {
845
+ var said = document.getElementById('copy-said');
846
+ function ok() { said.textContent = 'copied'; }
847
+ function no() { said.textContent = 'could not copy — select the text below and copy it'; }
848
+ if (navigator.clipboard && navigator.clipboard.writeText) {
849
+ navigator.clipboard.writeText(jsonBox.value).then(ok, fallback);
850
+ } else {
851
+ fallback();
852
+ }
853
+ function fallback() {
854
+ try {
855
+ jsonBox.focus();
856
+ jsonBox.select();
857
+ if (document.execCommand('copy')) ok(); else no();
858
+ } catch (err) { no(); }
859
+ }
860
+ });
861
+
862
+ fillReasonCodes(null);
863
+ })();
864
+ </script>
865
+ </body>
866
+ </html>
867
+ `;
868
+ return { html, manifest };
869
+ }