@conduction/docusaurus-preset 3.40.0 → 3.41.1
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/package.json +1 -1
- package/src/components/LockPick/LockPick.jsx +20 -7
- package/src/components/LockPick/LockPick.module.css +7 -0
- package/src/components/PaintByTokens/PaintByTokens.jsx +226 -0
- package/src/components/PaintByTokens/PaintByTokens.module.css +202 -0
- package/src/components/PaintByTokens/__tests__/engine.test.js +150 -0
- package/src/components/PaintByTokens/engine.js +194 -0
- package/src/components/Redaction/Redaction.jsx +259 -0
- package/src/components/Redaction/Redaction.module.css +180 -0
- package/src/components/Redaction/__tests__/engine.test.js +162 -0
- package/src/components/Redaction/engine.js +186 -0
- package/src/components/index.js +2 -0
package/package.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
29
29
|
import {translate} from '@docusaurus/Translate';
|
|
30
30
|
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
|
31
|
-
import {createGame, setPosition, turn, summarise, POSITIONS} from './engine';
|
|
31
|
+
import {createGame, setPosition, turn, give, summarise, POSITIONS} from './engine';
|
|
32
32
|
import styles from './LockPick.module.css';
|
|
33
33
|
|
|
34
34
|
const GAME_ID = 'lock-pick';
|
|
@@ -107,10 +107,18 @@ export default function LockPick({className}) {
|
|
|
107
107
|
|
|
108
108
|
const position = game ? game.position : Math.floor(POSITIONS / 2);
|
|
109
109
|
const last = game ? game.last : null;
|
|
110
|
-
/* The cylinder
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
/* The cylinder answers the pick as it moves, which is how this game
|
|
111
|
+
has always worked: you feel for the spot and only then commit. The
|
|
112
|
+
first version showed the last turn's result instead, so the only
|
|
113
|
+
way to learn anything was to turn, and every turn wore the pick
|
|
114
|
+
down. Sweeping was impossible and the game became a guessing game
|
|
115
|
+
with a cost per guess.
|
|
116
|
+
|
|
117
|
+
The feel is deliberately coarse, five buckets wide, so the dial
|
|
118
|
+
narrows the answer without handing it over: the last step is still
|
|
119
|
+
a commitment. */
|
|
120
|
+
const turned = game && running ? give(game) : 0;
|
|
121
|
+
const liveFeel = game && running ? feelCopy(turned) : null;
|
|
114
122
|
|
|
115
123
|
return (
|
|
116
124
|
<section className={[styles.lp, className].filter(Boolean).join(' ')} aria-labelledby="lock-pick-title">
|
|
@@ -145,7 +153,7 @@ export default function LockPick({className}) {
|
|
|
145
153
|
className={styles.cylinder}
|
|
146
154
|
role="img"
|
|
147
155
|
aria-label={translate(
|
|
148
|
-
{id: 'preset.lockPick.cylinder', message: 'The cylinder
|
|
156
|
+
{id: 'preset.lockPick.cylinder', message: 'The cylinder turns {percent} per cent where the pick is now', description: 'Accessible description of the lock cylinder. {percent} is how far it turns at the current pick position.'},
|
|
149
157
|
{percent: Math.round(turned * 100)},
|
|
150
158
|
)}>
|
|
151
159
|
<div className={styles.cylinderFill} style={{transform: `rotate(${-90 + turned * 80}deg)`}} />
|
|
@@ -167,6 +175,10 @@ export default function LockPick({className}) {
|
|
|
167
175
|
disabled={!running}
|
|
168
176
|
onChange={(e) => moveTo(Number(e.target.value))}
|
|
169
177
|
/>
|
|
178
|
+
<p className={styles.feel} role="status" aria-live="polite">
|
|
179
|
+
{liveFeel || translate({id: 'preset.lockPick.feelIdle', message: 'Take a pick to start feeling for it.', description: 'Placeholder where the live feel line sits before the game starts'})}
|
|
180
|
+
</p>
|
|
181
|
+
|
|
170
182
|
<div className={styles.pickRow}>
|
|
171
183
|
<span className={styles.pickLabel}>
|
|
172
184
|
{translate({id: 'preset.lockPick.wear', message: 'This pick', description: 'Label for the lock-pick durability bar'})}
|
|
@@ -176,7 +188,8 @@ export default function LockPick({className}) {
|
|
|
176
188
|
role="progressbar"
|
|
177
189
|
aria-valuemin={0}
|
|
178
190
|
aria-valuemax={100}
|
|
179
|
-
aria-valuenow={game ? game.durability : 100}
|
|
191
|
+
aria-valuenow={game ? game.durability : 100}
|
|
192
|
+
aria-label={translate({id: 'preset.lockPick.wearLabel', message: 'How much of this pick is left', description: 'Accessible name of the bar showing the current pick condition'})}>
|
|
180
193
|
<span
|
|
181
194
|
className={[styles.wearFill, game && game.durability <= 35 && styles.wearLow].filter(Boolean).join(' ')}
|
|
182
195
|
style={{width: `${game ? game.durability : 100}%`}}
|
|
@@ -128,6 +128,13 @@
|
|
|
128
128
|
}
|
|
129
129
|
.slider:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 4px; }
|
|
130
130
|
|
|
131
|
+
.feel {
|
|
132
|
+
margin: 10px 0 0;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
color: var(--c-cobalt-900);
|
|
135
|
+
min-height: 1.4em;
|
|
136
|
+
}
|
|
137
|
+
|
|
131
138
|
.pickRow {
|
|
132
139
|
display: flex;
|
|
133
140
|
align-items: center;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <PaintByTokens />
|
|
3
|
+
*
|
|
4
|
+
* Thematiq's mini-game. Paint by numbers, where the numbers are the
|
|
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
|
+
* The swatch is never the answer. Each cell says which token it wants,
|
|
10
|
+
* and what colour that token is depends on the theme the page is
|
|
11
|
+
* wearing, which is the habit the app exists to teach.
|
|
12
|
+
*
|
|
13
|
+
* The rules live in ./engine.js with no DOM and no clock.
|
|
14
|
+
*
|
|
15
|
+
* Usage on a product page:
|
|
16
|
+
*
|
|
17
|
+
* <PaintByTokens />
|
|
18
|
+
*
|
|
19
|
+
* Fires the shared `connext:gameend` event on game over, and listens
|
|
20
|
+
* for `connext:gamereplay`.
|
|
21
|
+
*
|
|
22
|
+
* Accessibility: every cell is a button that says which token it wants
|
|
23
|
+
* and whether it is filled, and the palette is a radio-style row on the
|
|
24
|
+
* number keys. Nothing here needs colour to be played, which is a
|
|
25
|
+
* strange thing to say about a painting game and the reason it works.
|
|
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, paint, select, step, timeLeft, remaining, summarise, TOKENS, COLS} from './engine';
|
|
32
|
+
import styles from './PaintByTokens.module.css';
|
|
33
|
+
|
|
34
|
+
const GAME_ID = 'paint-by-tokens';
|
|
35
|
+
const TICK_MS = 100;
|
|
36
|
+
|
|
37
|
+
function tokenLabel(token) {
|
|
38
|
+
switch (TOKENS[token]) {
|
|
39
|
+
case 'surface':
|
|
40
|
+
return translate({id: 'preset.paintByTokens.token.surface', message: 'surface', description: 'Name of the surface token in the paint-by-tokens game'});
|
|
41
|
+
case 'accent':
|
|
42
|
+
return translate({id: 'preset.paintByTokens.token.accent', message: 'accent', description: 'Name of the accent token in the paint-by-tokens game'});
|
|
43
|
+
case 'ink':
|
|
44
|
+
return translate({id: 'preset.paintByTokens.token.ink', message: 'ink', description: 'Name of the ink token in the paint-by-tokens game'});
|
|
45
|
+
case 'muted':
|
|
46
|
+
return translate({id: 'preset.paintByTokens.token.muted', message: 'muted', description: 'Name of the muted token in the paint-by-tokens game'});
|
|
47
|
+
default:
|
|
48
|
+
return translate({id: 'preset.paintByTokens.token.line', message: 'line', description: 'Name of the line token in the paint-by-tokens game'});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function PaintByTokens({className}) {
|
|
53
|
+
const {i18n} = useDocusaurusContext();
|
|
54
|
+
const locale = (i18n && i18n.currentLocale) || 'en';
|
|
55
|
+
|
|
56
|
+
const [game, setGame] = useState(null);
|
|
57
|
+
const [seconds, setSeconds] = useState(0);
|
|
58
|
+
const gameRef = useRef(null);
|
|
59
|
+
const startedAtRef = useRef(0);
|
|
60
|
+
const endedRef = useRef(false);
|
|
61
|
+
|
|
62
|
+
const running = Boolean(game) && !game.over;
|
|
63
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
|
|
64
|
+
|
|
65
|
+
const begin = useCallback(() => {
|
|
66
|
+
endedRef.current = false;
|
|
67
|
+
startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
68
|
+
const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
|
|
69
|
+
gameRef.current = fresh;
|
|
70
|
+
setGame(fresh);
|
|
71
|
+
setSeconds(Math.ceil(timeLeft(fresh, 0) / 1000));
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!running) return undefined;
|
|
76
|
+
const id = setInterval(() => {
|
|
77
|
+
const t = now();
|
|
78
|
+
const next = step(gameRef.current, t);
|
|
79
|
+
gameRef.current = next;
|
|
80
|
+
setGame(next);
|
|
81
|
+
setSeconds(Math.ceil(timeLeft(next, t) / 1000));
|
|
82
|
+
}, TICK_MS);
|
|
83
|
+
return () => clearInterval(id);
|
|
84
|
+
}, [running]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!game || !game.over || endedRef.current) return;
|
|
88
|
+
endedRef.current = true;
|
|
89
|
+
if (typeof window === 'undefined') return;
|
|
90
|
+
window.dispatchEvent(new CustomEvent('connext:gameend', {
|
|
91
|
+
detail: {
|
|
92
|
+
id: GAME_ID,
|
|
93
|
+
won: false,
|
|
94
|
+
score: game.score,
|
|
95
|
+
summary: summarise(game, locale),
|
|
96
|
+
title: translate({id: 'preset.paintByTokens.over.title', message: 'Time, and the theme is half painted.', description: 'Headline on the game-over dialog after a paint-by-tokens run'}),
|
|
97
|
+
subtitle: translate({id: 'preset.paintByTokens.over.subtitle', message: 'Every finished picture bought you time. Painting by eye spent it.', description: 'Subtitle on the game-over dialog after a paint-by-tokens run'}),
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
}, [game, locale]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (typeof window === 'undefined') return undefined;
|
|
104
|
+
const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
|
|
105
|
+
window.addEventListener('connext:gamereplay', onReplay);
|
|
106
|
+
return () => window.removeEventListener('connext:gamereplay', onReplay);
|
|
107
|
+
}, [begin]);
|
|
108
|
+
|
|
109
|
+
const pick = useCallback((token) => {
|
|
110
|
+
if (!gameRef.current) return;
|
|
111
|
+
const next = select(gameRef.current, token);
|
|
112
|
+
gameRef.current = next;
|
|
113
|
+
setGame(next);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const fill = useCallback((index) => {
|
|
117
|
+
if (!gameRef.current || gameRef.current.over) return;
|
|
118
|
+
const t = now();
|
|
119
|
+
const next = paint(gameRef.current, index, t);
|
|
120
|
+
gameRef.current = next;
|
|
121
|
+
setGame(next);
|
|
122
|
+
setSeconds(Math.ceil(timeLeft(next, t) / 1000));
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!running || typeof window === 'undefined') return undefined;
|
|
127
|
+
const onKey = (e) => {
|
|
128
|
+
const n = Number(e.key);
|
|
129
|
+
if (Number.isInteger(n) && n >= 1 && n <= TOKENS.length) {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
pick(n - 1);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
window.addEventListener('keydown', onKey);
|
|
135
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
136
|
+
}, [running, pick]);
|
|
137
|
+
|
|
138
|
+
const cells = game ? game.cells : [];
|
|
139
|
+
const selected = game ? game.selected : 0;
|
|
140
|
+
const last = game ? game.last : null;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<section className={[styles.pbt, className].filter(Boolean).join(' ')} aria-labelledby="paint-by-tokens-title">
|
|
144
|
+
<header className={styles.head}>
|
|
145
|
+
<div>
|
|
146
|
+
<p className={styles.eyebrow}>
|
|
147
|
+
{translate({id: 'preset.paintByTokens.eyebrow', message: 'Mini-game', description: 'Eyebrow above the paint-by-tokens game on a product page'})}
|
|
148
|
+
</p>
|
|
149
|
+
<h3 className={styles.title} id="paint-by-tokens-title">
|
|
150
|
+
{translate({id: 'preset.paintByTokens.title', message: 'Paint by tokens', description: 'Name of the Thematiq mini-game'})}
|
|
151
|
+
</h3>
|
|
152
|
+
<p className={styles.lede}>
|
|
153
|
+
{translate({id: 'preset.paintByTokens.lede', message: 'Paint by numbers, except the numbers are tokens. Every cell says which one it wants, and what colour that is depends on the theme, not on your eye.', description: 'One-line explanation of the paint-by-tokens rules'})}
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
<div className={styles.hud} role="status" aria-live="polite">
|
|
157
|
+
<span className={styles.hudPill}>
|
|
158
|
+
{translate({id: 'preset.paintByTokens.hud.score', message: 'Score {score}', description: 'Score readout on the paint-by-tokens HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
|
|
159
|
+
</span>
|
|
160
|
+
<span className={[styles.hudPill, running && seconds <= 5 && styles.hudLow].filter(Boolean).join(' ')}>
|
|
161
|
+
{translate({id: 'preset.paintByTokens.hud.time', message: '{seconds}s left', description: 'Remaining-time readout on the paint-by-tokens HUD'}, {seconds: running ? seconds : 0})}
|
|
162
|
+
</span>
|
|
163
|
+
<span className={styles.hudPill}>
|
|
164
|
+
{translate({id: 'preset.paintByTokens.hud.left', message: '{left} cells to go', description: 'Remaining-cells readout on the paint-by-tokens HUD'}, {left: game ? remaining(game) : 0})}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
</header>
|
|
168
|
+
|
|
169
|
+
<div className={styles.palette} role="group" aria-label={translate({id: 'preset.paintByTokens.paletteLabel', message: 'Tokens', description: 'Accessible name for the paint-by-tokens palette'})}>
|
|
170
|
+
{TOKENS.map((_, i) => (
|
|
171
|
+
<button
|
|
172
|
+
key={i}
|
|
173
|
+
type="button"
|
|
174
|
+
className={[styles.swatch, styles[`tone${i}`], i === selected && styles.swatchOn].filter(Boolean).join(' ')}
|
|
175
|
+
onClick={() => pick(i)}
|
|
176
|
+
disabled={!running}
|
|
177
|
+
aria-pressed={i === selected}>
|
|
178
|
+
<span className={styles.swatchKey} aria-hidden="true">{i + 1}</span>
|
|
179
|
+
<span className={styles.swatchName}>{tokenLabel(i)}</span>
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className={styles.grid} style={{gridTemplateColumns: `repeat(${COLS}, minmax(0, 1fr))`}}>
|
|
185
|
+
{cells.map((cell, i) => (
|
|
186
|
+
<button
|
|
187
|
+
key={i}
|
|
188
|
+
type="button"
|
|
189
|
+
className={[
|
|
190
|
+
styles.cell,
|
|
191
|
+
cell.painted && styles.cellPainted,
|
|
192
|
+
cell.painted && styles[`tone${cell.token}`],
|
|
193
|
+
].filter(Boolean).join(' ')}
|
|
194
|
+
onClick={() => fill(i)}
|
|
195
|
+
disabled={!running || cell.painted}
|
|
196
|
+
aria-label={cell.painted
|
|
197
|
+
? translate({id: 'preset.paintByTokens.cell.done', message: 'Filled with {token}', description: 'Accessible label for a painted cell'}, {token: tokenLabel(cell.token)})
|
|
198
|
+
: translate({id: 'preset.paintByTokens.cell.open', message: 'Wants {token}', description: 'Accessible label for an unpainted cell'}, {token: tokenLabel(cell.token)})}>
|
|
199
|
+
<span aria-hidden="true">{cell.painted ? '' : cell.token + 1}</span>
|
|
200
|
+
</button>
|
|
201
|
+
))}
|
|
202
|
+
{!game && (
|
|
203
|
+
<p className={styles.idle}>
|
|
204
|
+
{translate({id: 'preset.paintByTokens.idle', message: 'An empty theme, and five tokens to fill it with.', description: 'Placeholder before the paint-by-tokens game starts'})}
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<footer className={styles.foot}>
|
|
210
|
+
<button type="button" className={styles.start} onClick={begin}>
|
|
211
|
+
{game
|
|
212
|
+
? translate({id: 'preset.paintByTokens.restart', message: 'Restart', description: 'Button that restarts the paint-by-tokens game'})
|
|
213
|
+
: translate({id: 'preset.paintByTokens.start', message: 'Open a theme', description: 'Button that starts the paint-by-tokens game'})}
|
|
214
|
+
</button>
|
|
215
|
+
<p className={styles.hint} role="status" aria-live="polite">
|
|
216
|
+
{last && last.result === 'finished' && translate({id: 'preset.paintByTokens.feedback.finished', message: 'Theme done. That bought you twenty seconds.', description: 'Feedback after finishing a picture'})}
|
|
217
|
+
{last && last.result === 'wrong' && translate(
|
|
218
|
+
{id: 'preset.paintByTokens.feedback.wrong', message: 'That cell wanted {wanted}, not {used}. Three seconds gone.', description: 'Feedback after filling a cell with the wrong token'},
|
|
219
|
+
{wanted: tokenLabel(last.wanted), used: tokenLabel(last.used)},
|
|
220
|
+
)}
|
|
221
|
+
{(!last || last.result === 'painted' || last.result === 'timeout') && translate({id: 'preset.paintByTokens.hint', message: 'Pick a token with the number keys, then fill every cell that asks for it.', description: 'Hint under the paint-by-tokens board'})}
|
|
222
|
+
</p>
|
|
223
|
+
</footer>
|
|
224
|
+
</section>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <PaintByTokens /> styles.
|
|
3
|
+
*
|
|
4
|
+
* The five tones are the five tokens, drawn from the theme rather than
|
|
5
|
+
* from a palette of their own: that is the point of the game, and it
|
|
6
|
+
* means a site that reskins the preset reskins the picture with it.
|
|
7
|
+
*
|
|
8
|
+
* Every cell also carries its token as a number, so the board is
|
|
9
|
+
* playable by someone who cannot tell the tones apart.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.pbt {
|
|
13
|
+
border: 1px solid var(--c-cobalt-100);
|
|
14
|
+
border-radius: var(--radius-lg);
|
|
15
|
+
background: white;
|
|
16
|
+
padding: clamp(16px, 3vw, 28px);
|
|
17
|
+
font-family: var(--conduction-typography-font-family-body);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.head {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
gap: 16px;
|
|
24
|
+
align-items: flex-start;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
margin-bottom: 16px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.eyebrow {
|
|
30
|
+
margin: 0 0 4px;
|
|
31
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
32
|
+
font-size: 11px;
|
|
33
|
+
letter-spacing: 0.12em;
|
|
34
|
+
text-transform: uppercase;
|
|
35
|
+
color: var(--c-orange-knvb);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.title {
|
|
39
|
+
margin: 0 0 6px;
|
|
40
|
+
font-size: 20px;
|
|
41
|
+
font-weight: 700;
|
|
42
|
+
color: var(--c-cobalt-900);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.lede {
|
|
46
|
+
margin: 0;
|
|
47
|
+
max-width: 58ch;
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
line-height: 1.5;
|
|
50
|
+
color: var(--c-cobalt-700);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.hud {
|
|
54
|
+
display: flex;
|
|
55
|
+
gap: 8px;
|
|
56
|
+
flex-wrap: wrap;
|
|
57
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
font-variant-numeric: tabular-nums;
|
|
60
|
+
}
|
|
61
|
+
.hudPill {
|
|
62
|
+
padding: 6px 10px;
|
|
63
|
+
border-radius: var(--radius-pill);
|
|
64
|
+
background: var(--c-cobalt-50);
|
|
65
|
+
color: var(--c-cobalt-900);
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
68
|
+
.hudLow { background: var(--c-orange-knvb); color: white; }
|
|
69
|
+
|
|
70
|
+
/* The five tokens. Nothing else in the file names a colour. */
|
|
71
|
+
.tone0 { --pbt-tone: var(--c-cobalt-50); --pbt-ink: var(--c-cobalt-700); }
|
|
72
|
+
.tone1 { --pbt-tone: var(--c-blue-cobalt); --pbt-ink: white; }
|
|
73
|
+
.tone2 { --pbt-tone: var(--c-cobalt-900); --pbt-ink: white; }
|
|
74
|
+
.tone3 { --pbt-tone: var(--c-cobalt-300); --pbt-ink: var(--c-cobalt-900); }
|
|
75
|
+
.tone4 { --pbt-tone: var(--c-mint-500); --pbt-ink: var(--c-cobalt-900); }
|
|
76
|
+
|
|
77
|
+
.palette {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-wrap: wrap;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
margin-bottom: 12px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.swatch {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 8px;
|
|
88
|
+
padding: 7px 12px 7px 8px;
|
|
89
|
+
border: 1px solid var(--c-cobalt-200);
|
|
90
|
+
border-radius: var(--radius-pill);
|
|
91
|
+
background: white;
|
|
92
|
+
font-family: inherit;
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
color: var(--c-cobalt-700);
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
transition: border-color 120ms ease;
|
|
97
|
+
}
|
|
98
|
+
.swatch::before {
|
|
99
|
+
content: '';
|
|
100
|
+
width: 16px;
|
|
101
|
+
height: 16px;
|
|
102
|
+
border-radius: 4px;
|
|
103
|
+
background: var(--pbt-tone);
|
|
104
|
+
border: 1px solid var(--c-cobalt-200);
|
|
105
|
+
}
|
|
106
|
+
.swatch:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
107
|
+
.swatch:disabled { opacity: 0.55; cursor: default; }
|
|
108
|
+
.swatch:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
|
|
109
|
+
|
|
110
|
+
.swatchOn {
|
|
111
|
+
border-color: var(--c-blue-cobalt);
|
|
112
|
+
background: var(--c-cobalt-50);
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
color: var(--c-cobalt-900);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.swatchKey {
|
|
118
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
119
|
+
font-size: 10px;
|
|
120
|
+
color: var(--c-cobalt-400);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.swatchName { white-space: nowrap; }
|
|
124
|
+
|
|
125
|
+
.grid {
|
|
126
|
+
display: grid;
|
|
127
|
+
gap: 3px;
|
|
128
|
+
max-width: 460px;
|
|
129
|
+
padding: 10px;
|
|
130
|
+
border-radius: var(--radius-md);
|
|
131
|
+
background: var(--c-cobalt-50);
|
|
132
|
+
min-height: 120px;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.cell {
|
|
136
|
+
aspect-ratio: 1;
|
|
137
|
+
border: 1px solid var(--c-cobalt-200);
|
|
138
|
+
border-radius: var(--radius-sm);
|
|
139
|
+
background: white;
|
|
140
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
141
|
+
font-size: 10px;
|
|
142
|
+
color: var(--c-cobalt-400);
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
padding: 0;
|
|
145
|
+
transition: background 120ms ease, border-color 120ms ease;
|
|
146
|
+
}
|
|
147
|
+
.cell:hover:not(:disabled) { border-color: var(--c-blue-cobalt); }
|
|
148
|
+
.cell:disabled { cursor: default; }
|
|
149
|
+
.cell:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 1px; }
|
|
150
|
+
|
|
151
|
+
.cellPainted {
|
|
152
|
+
background: var(--pbt-tone);
|
|
153
|
+
border-color: var(--pbt-tone);
|
|
154
|
+
color: var(--pbt-ink);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.idle {
|
|
158
|
+
grid-column: 1 / -1;
|
|
159
|
+
margin: auto;
|
|
160
|
+
font-size: 14px;
|
|
161
|
+
color: var(--c-cobalt-400);
|
|
162
|
+
text-align: center;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.foot {
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-wrap: wrap;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 12px;
|
|
170
|
+
margin-top: 14px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.start {
|
|
174
|
+
background: var(--c-blue-cobalt);
|
|
175
|
+
color: white;
|
|
176
|
+
border: 1px solid var(--c-blue-cobalt);
|
|
177
|
+
padding: 10px 18px;
|
|
178
|
+
border-radius: var(--radius-md);
|
|
179
|
+
font-family: inherit;
|
|
180
|
+
font-weight: 500;
|
|
181
|
+
font-size: 14px;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
transition: background 120ms ease;
|
|
184
|
+
}
|
|
185
|
+
.start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
|
|
186
|
+
.start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
|
|
187
|
+
|
|
188
|
+
.hint {
|
|
189
|
+
margin: 0;
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
color: var(--c-cobalt-400);
|
|
192
|
+
max-width: 52ch;
|
|
193
|
+
min-height: 1.5em;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
197
|
+
.cellPainted { animation: pbtFill 140ms ease-out; }
|
|
198
|
+
@keyframes pbtFill {
|
|
199
|
+
from { transform: scale(0.8); }
|
|
200
|
+
to { transform: scale(1); }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -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
|
+
});
|