@conduction/docusaurus-preset 3.39.0 → 3.41.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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * engine.test.js — the paint-by-tokens rules.
3
+ *
4
+ * The property that matters most is the dullest: every picture must be
5
+ * exactly ROWS × COLS cells of tokens that exist. A picture with a
6
+ * short row or a stray digit renders as a hole in the grid and a cell
7
+ * nobody can ever fill, which ends the run through no fault of the
8
+ * player.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const test = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+
16
+ const {
17
+ createGame, paint, select, step, timeLeft, remaining, summarise,
18
+ TOKENS, PICTURES, COLS, ROWS, DEFAULTS,
19
+ } = require('../engine.js');
20
+
21
+ /** Fill the current picture correctly, selecting each token as needed. */
22
+ function finishPicture(state, now = 0) {
23
+ let s = state;
24
+ for (let i = 0; i < s.cells.length; i++) {
25
+ if (s.cells[i].painted) continue;
26
+ s = select(s, s.cells[i].token);
27
+ s = paint(s, i, now);
28
+ if (s.over) break;
29
+ /* The picture is replaced the moment it is finished, so stop
30
+ walking this one as soon as the cell count resets. */
31
+ if (remaining(s) === COLS * ROWS) break;
32
+ }
33
+ return s;
34
+ }
35
+
36
+ test('every picture is a full grid of tokens that exist', () => {
37
+ for (const picture of PICTURES) {
38
+ assert.equal(picture.rows.length, ROWS, `${picture.name}: wrong number of rows`);
39
+ for (const row of picture.rows) {
40
+ assert.equal(row.length, COLS, `${picture.name}: a row is not ${COLS} cells`);
41
+ for (const ch of row) {
42
+ const token = Number(ch);
43
+ assert.ok(Number.isInteger(token), `${picture.name}: "${ch}" is not a token`);
44
+ assert.ok(token >= 0 && token < TOKENS.length, `${picture.name}: token ${token} does not exist`);
45
+ }
46
+ }
47
+ }
48
+ });
49
+
50
+ test('a game starts with a full picture, a clock and nothing painted', () => {
51
+ const s = createGame({seed: 1, now: 0});
52
+ assert.equal(s.cells.length, COLS * ROWS);
53
+ assert.equal(remaining(s), COLS * ROWS);
54
+ assert.equal(timeLeft(s, 0), DEFAULTS.startMs);
55
+ assert.equal(s.over, false);
56
+ });
57
+
58
+ test('painting a cell with the token it asks for fills it and scores', () => {
59
+ let s = createGame({seed: 2, now: 0});
60
+ s = select(s, s.cells[0].token);
61
+ s = paint(s, 0, 10);
62
+ assert.equal(s.cells[0].painted, true);
63
+ assert.equal(s.score, DEFAULTS.pointsPerCell);
64
+ assert.equal(s.last.result, 'painted');
65
+ });
66
+
67
+ test('the wrong token costs time, leaves the cell empty, and says what was wanted', () => {
68
+ let s = createGame({seed: 3, now: 0});
69
+ const wanted = s.cells[0].token;
70
+ const wrong = (wanted + 1) % TOKENS.length;
71
+ const deadline = s.endsAt;
72
+ s = select(s, wrong);
73
+ s = paint(s, 0, 20);
74
+ assert.equal(s.cells[0].painted, false);
75
+ assert.equal(s.endsAt, deadline - DEFAULTS.penaltyMs);
76
+ assert.equal(s.wrong, 1);
77
+ assert.equal(s.last.wanted, wanted);
78
+ assert.equal(s.last.used, wrong);
79
+ });
80
+
81
+ test('painting over a finished cell is a slip, not a mistake', () => {
82
+ let s = createGame({seed: 4, now: 0});
83
+ s = select(s, s.cells[0].token);
84
+ s = paint(s, 0, 10);
85
+ const deadline = s.endsAt;
86
+ const score = s.score;
87
+ s = paint(s, 0, 20);
88
+ assert.equal(s.endsAt, deadline, 'a second click on a filled cell cost time');
89
+ assert.equal(s.score, score, 'a second click on a filled cell scored again');
90
+ });
91
+
92
+ test('an unknown token cannot be selected, and an unknown cell cannot be painted', () => {
93
+ const s = createGame({seed: 5, now: 0});
94
+ assert.equal(select(s, 99).selected, s.selected);
95
+ assert.equal(select(s, -1).selected, s.selected);
96
+ assert.equal(paint(s, 9999, 10), s);
97
+ });
98
+
99
+ test('finishing a picture scores, buys time and deals the next one', () => {
100
+ let s = createGame({seed: 6, now: 0});
101
+ const first = s.picture;
102
+ const deadline = s.endsAt;
103
+ s = finishPicture(s, 0);
104
+ assert.equal(s.finished, 1);
105
+ assert.ok(s.score >= DEFAULTS.pointsPerPicture);
106
+ assert.ok(s.endsAt > deadline, 'finishing a picture bought no time');
107
+ assert.equal(remaining(s), COLS * ROWS, 'the next picture started part-painted');
108
+ assert.notEqual(s.picture, first, 'the same picture was dealt twice in a row');
109
+ });
110
+
111
+ test('every picture in the rotation can actually be finished', () => {
112
+ let s = createGame({seed: 7, now: 0});
113
+ const seen = new Set();
114
+ for (let i = 0; i < PICTURES.length + 1; i++) {
115
+ seen.add(s.picture);
116
+ s = finishPicture(s, 0);
117
+ assert.equal(s.over, false, `run ended while finishing picture ${i}`);
118
+ }
119
+ assert.equal(seen.size, PICTURES.length, 'the rotation does not reach every picture');
120
+ assert.equal(s.finished, PICTURES.length + 1);
121
+ });
122
+
123
+ test('the clock ends the run, and nothing paints afterwards', () => {
124
+ let s = createGame({seed: 8, now: 0});
125
+ s = step(s, DEFAULTS.startMs + 1);
126
+ assert.equal(s.over, true);
127
+ const frozen = paint(select(s, s.cells[0].token), 0, DEFAULTS.startMs + 5);
128
+ assert.equal(frozen.score, s.score);
129
+ });
130
+
131
+ test('a penalty that empties the clock ends the run there and then', () => {
132
+ let s = createGame({seed: 9, now: 0, config: {startMs: 2000}});
133
+ const wrong = (s.cells[0].token + 1) % TOKENS.length;
134
+ s = paint(select(s, wrong), 0, 100);
135
+ assert.equal(s.over, true);
136
+ assert.equal(timeLeft(s, 100), 0);
137
+ });
138
+
139
+ test('the same seed opens on the same picture, a different one need not', () => {
140
+ const first = (seed) => createGame({seed, now: 0}).picture;
141
+ assert.equal(first(21), first(21));
142
+ const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(first));
143
+ assert.ok(spread.size > 1, 'every seed opens on the same picture');
144
+ });
145
+
146
+ test('the summary reads as a sentence in both locales', () => {
147
+ const s = {finished: 3, wrong: 2};
148
+ assert.match(summarise(s, 'en'), /3 themes finished · 2 wrong fills/);
149
+ assert.match(summarise(s, 'nl'), /3 thema's af · 2 keer misgetikt/);
150
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Paint by tokens — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Thematiq's game. Paint by numbers, except the numbers are the design
5
+ * tokens a theme is made of: surface, accent, ink, muted, line. Pick a
6
+ * token, fill the cells that ask for it, finish the picture before the
7
+ * clock runs out.
8
+ *
9
+ * It is a theming game rather than a colouring game, and the
10
+ * difference is that the swatch is never the answer: the cell says
11
+ * which token it wants, and a token's colour is whatever the active
12
+ * theme says it is. Painting by eye is exactly the habit the app
13
+ * exists to break.
14
+ *
15
+ * Time and randomness are injected. The component owns the clock.
16
+ */
17
+
18
+ /* The five token roles, in the order the palette shows them. Their
19
+ colours belong to the theme, not to this file. */
20
+ export const TOKENS = ['surface', 'accent', 'ink', 'muted', 'line'];
21
+
22
+ export const COLS = 8;
23
+ export const ROWS = 6;
24
+
25
+ /* Each picture is a row-per-string map of token indexes. They are
26
+ deliberately readable as pictures in the source, because a picture
27
+ nobody can see while editing is a picture nobody notices breaking. */
28
+ export const PICTURES = [
29
+ {
30
+ name: 'hex',
31
+ rows: [
32
+ '00111100',
33
+ '01444410',
34
+ '14222241',
35
+ '14222241',
36
+ '01444410',
37
+ '00111100',
38
+ ],
39
+ },
40
+ {
41
+ name: 'stack',
42
+ rows: [
43
+ '00000000',
44
+ '11111111',
45
+ '13333331',
46
+ '11111111',
47
+ '12222221',
48
+ '11111111',
49
+ ],
50
+ },
51
+ {
52
+ name: 'record',
53
+ rows: [
54
+ '01111110',
55
+ '01333310',
56
+ '01222210',
57
+ '01222210',
58
+ '01333310',
59
+ '01111110',
60
+ ],
61
+ },
62
+ {
63
+ name: 'flow',
64
+ rows: [
65
+ '40000004',
66
+ '04000040',
67
+ '00422400',
68
+ '00422400',
69
+ '04000040',
70
+ '40000004',
71
+ ],
72
+ },
73
+ ];
74
+
75
+ export const DEFAULTS = {
76
+ startMs: 45000,
77
+ bonusMs: 20000,
78
+ /* A wrong fill costs time rather than a life: the mistake in theming
79
+ is picking by eye, and the cost of that is rework, not disaster. */
80
+ penaltyMs: 3000,
81
+ pointsPerCell: 2,
82
+ pointsPerPicture: 30,
83
+ };
84
+
85
+ function mulberry32(seed) {
86
+ let a = seed >>> 0;
87
+ return function random() {
88
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
89
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
90
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
91
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
92
+ };
93
+ }
94
+
95
+ function loadPicture(state, index) {
96
+ const picture = PICTURES[index % PICTURES.length];
97
+ const cells = [];
98
+ for (let r = 0; r < ROWS; r++) {
99
+ for (let c = 0; c < COLS; c++) {
100
+ const token = Number(picture.rows[r][c]);
101
+ cells.push({token, painted: false});
102
+ }
103
+ }
104
+ return {...state, picture: picture.name, cells, pictureIndex: index};
105
+ }
106
+
107
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
108
+ const cfg = {...DEFAULTS, ...config};
109
+ const random = mulberry32(seed);
110
+ const base = {
111
+ cfg,
112
+ random,
113
+ selected: 0,
114
+ score: 0,
115
+ finished: 0,
116
+ wrong: 0,
117
+ endsAt: now + cfg.startMs,
118
+ last: null,
119
+ over: false,
120
+ };
121
+ return loadPicture(base, Math.floor(random() * PICTURES.length));
122
+ }
123
+
124
+ export function timeLeft(state, now) {
125
+ return Math.max(0, state.endsAt - now);
126
+ }
127
+
128
+ export function select(state, token) {
129
+ if (state.over) return state;
130
+ if (!Number.isInteger(token) || token < 0 || token >= TOKENS.length) return state;
131
+ return {...state, selected: token};
132
+ }
133
+
134
+ export function step(state, now) {
135
+ if (state.over) return state;
136
+ if (timeLeft(state, now) > 0) return state;
137
+ return {...state, over: true, last: {result: 'timeout', at: now}};
138
+ }
139
+
140
+ /** How many cells of the current picture are still empty. */
141
+ export function remaining(state) {
142
+ return state.cells.filter((c) => !c.painted).length;
143
+ }
144
+
145
+ /**
146
+ * Fill one cell with the selected token.
147
+ *
148
+ * A cell that already carries paint is left alone and costs nothing:
149
+ * clicking twice is a slip, not a mistake about the theme.
150
+ */
151
+ export function paint(state, index, now) {
152
+ if (state.over) return state;
153
+ const cell = state.cells[index];
154
+ if (!cell || cell.painted) return state;
155
+
156
+ if (cell.token !== state.selected) {
157
+ const out = {
158
+ ...state,
159
+ endsAt: state.endsAt - state.cfg.penaltyMs,
160
+ wrong: state.wrong + 1,
161
+ last: {result: 'wrong', wanted: cell.token, used: state.selected, at: now},
162
+ };
163
+ return timeLeft(out, now) > 0 ? out : {...out, over: true};
164
+ }
165
+
166
+ const cells = [...state.cells];
167
+ cells[index] = {...cell, painted: true};
168
+ const next = {
169
+ ...state,
170
+ cells,
171
+ score: state.score + state.cfg.pointsPerCell,
172
+ last: {result: 'painted', at: now},
173
+ };
174
+
175
+ if (cells.some((c) => !c.painted)) return next;
176
+
177
+ /* Picture finished: score it, buy time, and deal the next one. */
178
+ const done = {
179
+ ...next,
180
+ score: next.score + next.cfg.pointsPerPicture,
181
+ finished: next.finished + 1,
182
+ endsAt: next.endsAt + next.cfg.bonusMs,
183
+ last: {result: 'finished', at: now},
184
+ };
185
+ return loadPicture(done, done.pictureIndex + 1);
186
+ }
187
+
188
+ /** The line that goes on the game-over card and into the post. */
189
+ export function summarise(state, locale = 'en') {
190
+ const n = (v) => Number(v || 0).toLocaleString(locale);
191
+ return locale === 'nl'
192
+ ? `${n(state.finished)} thema's af · ${n(state.wrong)} keer misgetikt`
193
+ : `${n(state.finished)} themes finished · ${n(state.wrong)} wrong fills`;
194
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * <Redaction />
3
+ *
4
+ * Filinq's mini-game. A document is going out. Black out everything in
5
+ * it that may not be published, then publish it, before the clock does
6
+ * that for you.
7
+ *
8
+ * Both mistakes cost, and they cost differently: leaving a name or a
9
+ * citizen number visible is a breach and costs a life, blacking out
10
+ * half the page costs points. A game that punished both the same would
11
+ * teach people to redact everything, which is the other way of failing
12
+ * at this.
13
+ *
14
+ * The rules live in ./engine.js with no DOM and no clock.
15
+ *
16
+ * Usage on a product page:
17
+ *
18
+ * <Redaction />
19
+ *
20
+ * Fires the shared `connext:gameend` event on game over, and listens
21
+ * for `connext:gamereplay`.
22
+ *
23
+ * Accessibility: every word is a toggle button that says whether it is
24
+ * blacked out, so the document can be read and redacted from the
25
+ * keyboard, and the countdown is announced rather than only drawn.
26
+ */
27
+
28
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
29
+ import {translate} from '@docusaurus/Translate';
30
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
31
+ import {createGame, black, publish, step, remaining, summarise} from './engine';
32
+ import styles from './Redaction.module.css';
33
+
34
+ const GAME_ID = 'redaction';
35
+ const TICK_MS = 100;
36
+
37
+ /* The words of every document. Kept here rather than in the engine so
38
+ the rules stay free of copy, and so each phrase is a translatable
39
+ string in its own right. */
40
+ function tokenText(key) {
41
+ switch (key) {
42
+ case 'permitIntro':
43
+ return translate({id: 'preset.redaction.token.permitIntro', message: 'Permit granted to', description: 'Redaction document text'});
44
+ case 'permitMiddle':
45
+ return translate({id: 'preset.redaction.token.permitMiddle', message: 'for a dormer at', description: 'Redaction document text'});
46
+ case 'permitTail':
47
+ return translate({id: 'preset.redaction.token.permitTail', message: 'applicant reference', description: 'Redaction document text'});
48
+ case 'permitEnd':
49
+ return translate({id: 'preset.redaction.token.permitEnd', message: 'Objections within six weeks.', description: 'Redaction document text'});
50
+ case 'invoiceIntro':
51
+ return translate({id: 'preset.redaction.token.invoiceIntro', message: 'Invoice for', description: 'Redaction document text'});
52
+ case 'invoiceMiddle':
53
+ return translate({id: 'preset.redaction.token.invoiceMiddle', message: 'payable to', description: 'Redaction document text'});
54
+ case 'invoiceTail':
55
+ return translate({id: 'preset.redaction.token.invoiceTail', message: 'total', description: 'Redaction document text'});
56
+ case 'invoiceEnd':
57
+ return translate({id: 'preset.redaction.token.invoiceEnd', message: 'Questions to', description: 'Redaction document text'});
58
+ case 'objectionIntro':
59
+ return translate({id: 'preset.redaction.token.objectionIntro', message: 'Objection filed by', description: 'Redaction document text'});
60
+ case 'objectionMiddle':
61
+ return translate({id: 'preset.redaction.token.objectionMiddle', message: 'born', description: 'Redaction document text'});
62
+ case 'objectionTail':
63
+ return translate({id: 'preset.redaction.token.objectionTail', message: 'against decision', description: 'Redaction document text'});
64
+ case 'objectionEnd':
65
+ return translate({id: 'preset.redaction.token.objectionEnd', message: 'Hearing on the fourteenth.', description: 'Redaction document text'});
66
+ case 'reportIntro':
67
+ return translate({id: 'preset.redaction.token.reportIntro', message: 'Inspection report from', description: 'Redaction document text'});
68
+ case 'reportMiddle':
69
+ return translate({id: 'preset.redaction.token.reportMiddle', message: 'contact on', description: 'Redaction document text'});
70
+ case 'reportTail':
71
+ return translate({id: 'preset.redaction.token.reportTail', message: 'inspector', description: 'Redaction document text'});
72
+ case 'reportEnd':
73
+ return translate({id: 'preset.redaction.token.reportEnd', message: 'Published under', description: 'Redaction document text'});
74
+ case 'name':
75
+ return translate({id: 'preset.redaction.token.name', message: 'J. de Vries', description: 'Redaction document text: a personal name, which must be redacted'});
76
+ case 'address':
77
+ return translate({id: 'preset.redaction.token.address', message: 'Keizersgracht 12', description: 'Redaction document text: an address, which must be redacted'});
78
+ case 'bsn':
79
+ return translate({id: 'preset.redaction.token.bsn', message: 'BSN 1234 56 789', description: 'Redaction document text: a citizen service number, which must be redacted'});
80
+ case 'iban':
81
+ return translate({id: 'preset.redaction.token.iban', message: 'NL91 ABNA 0417 1643 00', description: 'Redaction document text: a bank account, which must be redacted'});
82
+ case 'email':
83
+ return translate({id: 'preset.redaction.token.email', message: 'j.devries@example.nl', description: 'Redaction document text: an email address, which must be redacted'});
84
+ case 'birthdate':
85
+ return translate({id: 'preset.redaction.token.birthdate', message: '4 March 1971', description: 'Redaction document text: a date of birth, which must be redacted'});
86
+ case 'phone':
87
+ return translate({id: 'preset.redaction.token.phone', message: '06 1234 5678', description: 'Redaction document text: a phone number, which must be redacted'});
88
+ case 'company':
89
+ return translate({id: 'preset.redaction.token.company', message: 'Bakkerij Janssen BV', description: 'Redaction document text: a company name, which may be published'});
90
+ case 'amount':
91
+ return translate({id: 'preset.redaction.token.amount', message: '1,240 euro', description: 'Redaction document text: an amount, which may be published'});
92
+ case 'caseNumber':
93
+ return translate({id: 'preset.redaction.token.caseNumber', message: 'case 2026-118', description: 'Redaction document text: a case number, which may be published'});
94
+ case 'department':
95
+ return translate({id: 'preset.redaction.token.department', message: 'the building department', description: 'Redaction document text: a department, which may be published'});
96
+ default:
97
+ return translate({id: 'preset.redaction.token.policy', message: 'the open government act', description: 'Redaction document text: a law, which may be published'});
98
+ }
99
+ }
100
+
101
+ export default function Redaction({className}) {
102
+ const {i18n} = useDocusaurusContext();
103
+ const locale = (i18n && i18n.currentLocale) || 'en';
104
+
105
+ const [game, setGame] = useState(null);
106
+ const [left, setLeft] = useState(1);
107
+ const gameRef = useRef(null);
108
+ const startedAtRef = useRef(0);
109
+ const endedRef = useRef(false);
110
+
111
+ const running = Boolean(game) && !game.over;
112
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
113
+
114
+ const begin = useCallback(() => {
115
+ endedRef.current = false;
116
+ startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
117
+ const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
118
+ gameRef.current = fresh;
119
+ setGame(fresh);
120
+ setLeft(1);
121
+ }, []);
122
+
123
+ useEffect(() => {
124
+ if (!running) return undefined;
125
+ const id = setInterval(() => {
126
+ const t = now();
127
+ const next = step(gameRef.current, t);
128
+ gameRef.current = next;
129
+ setGame(next);
130
+ setLeft(remaining(next, t));
131
+ }, TICK_MS);
132
+ return () => clearInterval(id);
133
+ }, [running]);
134
+
135
+ useEffect(() => {
136
+ if (!game || !game.over || endedRef.current) return;
137
+ endedRef.current = true;
138
+ if (typeof window === 'undefined') return;
139
+ window.dispatchEvent(new CustomEvent('connext:gameend', {
140
+ detail: {
141
+ id: GAME_ID,
142
+ won: false,
143
+ score: game.score,
144
+ summary: summarise(game, locale),
145
+ title: translate({id: 'preset.redaction.over.title', message: 'Three of those went out with a name still on them.', description: 'Headline on the game-over dialog after a redaction run'}),
146
+ subtitle: translate({id: 'preset.redaction.over.subtitle', message: 'Which is the part that makes the news, not the paperwork.', description: 'Subtitle on the game-over dialog after a redaction run'}),
147
+ },
148
+ }));
149
+ }, [game, locale]);
150
+
151
+ useEffect(() => {
152
+ if (typeof window === 'undefined') return undefined;
153
+ const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
154
+ window.addEventListener('connext:gamereplay', onReplay);
155
+ return () => window.removeEventListener('connext:gamereplay', onReplay);
156
+ }, [begin]);
157
+
158
+ const toggle = useCallback((index) => {
159
+ if (!gameRef.current || gameRef.current.over) return;
160
+ const next = black(gameRef.current, index);
161
+ gameRef.current = next;
162
+ setGame(next);
163
+ }, []);
164
+
165
+ const send = useCallback(() => {
166
+ if (!gameRef.current || gameRef.current.over) return;
167
+ const t = now();
168
+ const next = publish(gameRef.current, t);
169
+ gameRef.current = next;
170
+ setGame(next);
171
+ setLeft(remaining(next, t));
172
+ }, []);
173
+
174
+ const doc = game ? game.doc : null;
175
+ const last = game ? game.last : null;
176
+ const pct = Math.round(left * 100);
177
+
178
+ return (
179
+ <section className={[styles.rd, className].filter(Boolean).join(' ')} aria-labelledby="redaction-title">
180
+ <header className={styles.head}>
181
+ <div>
182
+ <p className={styles.eyebrow}>
183
+ {translate({id: 'preset.redaction.eyebrow', message: 'Mini-game', description: 'Eyebrow above the redaction game on a product page'})}
184
+ </p>
185
+ <h3 className={styles.title} id="redaction-title">
186
+ {translate({id: 'preset.redaction.title', message: 'Black it out', description: 'Name of the Filinq mini-game'})}
187
+ </h3>
188
+ <p className={styles.lede}>
189
+ {translate({id: 'preset.redaction.lede', message: 'This document is going out. Black out what may not be published, then send it. Leave one thing in and it is a breach; black out the whole page and you have published stripes.', description: 'One-line explanation of the redaction rules'})}
190
+ </p>
191
+ </div>
192
+ <div className={styles.hud} role="status" aria-live="polite">
193
+ <span className={styles.hudPill}>
194
+ {translate({id: 'preset.redaction.hud.score', message: 'Score {score}', description: 'Score readout on the redaction HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
195
+ </span>
196
+ <span className={styles.hudPill}>
197
+ {translate({id: 'preset.redaction.hud.lives', message: 'Breaches left {lives}', description: 'Remaining-lives readout on the redaction HUD'}, {lives: game ? game.lives : 3})}
198
+ </span>
199
+ </div>
200
+ </header>
201
+
202
+ <div className={styles.paper}>
203
+ {doc ? (
204
+ <>
205
+ <p className={styles.doc}>
206
+ {doc.tokens.map((token, i) => (
207
+ <button
208
+ key={i}
209
+ type="button"
210
+ className={[styles.word, token.blacked && styles.wordBlacked].filter(Boolean).join(' ')}
211
+ onClick={() => toggle(i)}
212
+ disabled={!running || token.blacked}
213
+ aria-pressed={token.blacked}
214
+ aria-label={token.blacked
215
+ ? translate({id: 'preset.redaction.word.blacked', message: '{text}, blacked out', description: 'Accessible label for a redacted word'}, {text: tokenText(token.t)})
216
+ : tokenText(token.t)}>
217
+ <span aria-hidden="true">{tokenText(token.t)}</span>
218
+ </button>
219
+ ))}
220
+ </p>
221
+ <div
222
+ className={styles.clock}
223
+ role="progressbar"
224
+ aria-valuemin={0}
225
+ aria-valuemax={100}
226
+ aria-valuenow={pct}
227
+ aria-label={translate({id: 'preset.redaction.clock', message: 'Time before this document publishes itself', description: 'Accessible name of the redaction countdown'})}>
228
+ <div className={[styles.clockFill, left < 0.3 && styles.clockLow].filter(Boolean).join(' ')} style={{width: `${pct}%`}} />
229
+ </div>
230
+ </>
231
+ ) : (
232
+ <p className={styles.idle}>
233
+ {translate({id: 'preset.redaction.idle', message: 'A stack of documents, all of them due out today.', description: 'Placeholder before the redaction game starts'})}
234
+ </p>
235
+ )}
236
+ </div>
237
+
238
+ <footer className={styles.foot}>
239
+ <button type="button" className={styles.publish} onClick={send} disabled={!running || !doc}>
240
+ {translate({id: 'preset.redaction.publish', message: 'Publish it', description: 'Button that publishes the redacted document'})}
241
+ </button>
242
+ <button type="button" className={styles.start} onClick={begin}>
243
+ {game
244
+ ? translate({id: 'preset.redaction.restart', message: 'Restart', description: 'Button that restarts the redaction game'})
245
+ : translate({id: 'preset.redaction.start', message: 'Open the stack', description: 'Button that starts the redaction game'})}
246
+ </button>
247
+ <p className={styles.hint} role="status" aria-live="polite">
248
+ {last && last.result === 'breach' && translate({id: 'preset.redaction.feedback.breach', message: 'That went out with something on it that should not have. One breach.', description: 'Feedback after publishing a document with a secret still visible'})}
249
+ {last && last.result === 'clean' && translate({id: 'preset.redaction.feedback.clean', message: 'Clean. Everything that had to go is gone, and the rest is still readable.', description: 'Feedback after publishing a perfectly redacted document'})}
250
+ {last && last.result === 'overRedacted' && translate(
251
+ {id: 'preset.redaction.feedback.over', message: 'Safe, but you blacked out {over} word(s) that could have stayed.', description: 'Feedback after publishing an over-redacted document. {over} is how many ordinary words were blacked out.'},
252
+ {over: last.over},
253
+ )}
254
+ {!last && translate({id: 'preset.redaction.hint', message: 'Click a word to black it out. Names, numbers and addresses go; the sentence around them stays.', description: 'Hint under the redaction document'})}
255
+ </p>
256
+ </footer>
257
+ </section>
258
+ );
259
+ }