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/README.md +92 -11
- package/cli.ts +366 -15
- package/docs/AUTHORING.md +290 -9
- package/package.json +1 -1
- package/src/ballot.ts +869 -0
- package/src/compile.ts +612 -0
- package/src/preview.ts +2 -2
- package/src/types.ts +163 -0
- package/src/validate.ts +229 -1
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
|
+
}
|