@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,172 @@
1
+ /**
2
+ * Lock pick — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Keepiq's game, and the one everybody already knows how to play: set
5
+ * the pick, turn the cylinder, feel how far it gives. Close to the
6
+ * sweet spot it turns; far from it the pick strains and eventually
7
+ * snaps. Three picks and the lock wins.
8
+ *
9
+ * The feedback is graded rather than binary, which is what makes it a
10
+ * game of deduction instead of a lottery: every turn tells you how
11
+ * close you were, so the next one is a better guess. It also means the
12
+ * lock is pickable without seeing anything, which matters more here
13
+ * than the animation does.
14
+ *
15
+ * Nothing in here runs on a clock. A lock is a state machine, not a
16
+ * race, and that is the difference between this game and the other
17
+ * four.
18
+ */
19
+
20
+ export const POSITIONS = 100;
21
+
22
+ export const DEFAULTS = {
23
+ picks: 3,
24
+ /* How far off the sweet spot the pick may be and still open the
25
+ lock, as positions on the dial. Narrows each time a lock opens,
26
+ down to a floor that is still findable by halving the range. */
27
+ toleranceStart: 9,
28
+ toleranceFloor: 3,
29
+ toleranceStep: 1,
30
+ /* A pick survives a few bad turns near the spot and one wild one.
31
+ Strain is the square of how far off you were, so the punishment
32
+ for a guess a long way out is what ends a pick, not patience. */
33
+ durability: 100,
34
+ strainScale: 2.4,
35
+ /* No single turn may snap a fresh pick. Without this cap a guess at
36
+ the far end of the dial costs a whole pick, so a player who has
37
+ not found the spot yet loses all three before learning anything,
38
+ and the graded feedback the game is built on never gets read. Two
39
+ wild turns in a row still end a pick. */
40
+ maxStrain: 55,
41
+ pointsPerLock: 20,
42
+ /* What is left of the pick when the lock opens is worth points: it
43
+ rewards deduction over brute force, which is the whole game. */
44
+ durabilityBonusDivisor: 5,
45
+ };
46
+
47
+ function mulberry32(seed) {
48
+ let a = seed >>> 0;
49
+ return function random() {
50
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
51
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
52
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
53
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
+ };
55
+ }
56
+
57
+ function newLock(state) {
58
+ const tolerance = Math.max(
59
+ state.cfg.toleranceFloor,
60
+ state.cfg.toleranceStart - state.opened * state.cfg.toleranceStep,
61
+ );
62
+ /* Keep the sweet spot off the very edges: a lock whose answer is 0
63
+ or 99 is opened by the two guesses everybody tries first. */
64
+ const margin = tolerance + 2;
65
+ const span = POSITIONS - 1 - margin * 2;
66
+ return {
67
+ sweet: margin + Math.floor(state.random() * (span + 1)),
68
+ tolerance,
69
+ attempts: 0,
70
+ };
71
+ }
72
+
73
+ export function createGame({seed = Date.now(), config = {}} = {}) {
74
+ const cfg = {...DEFAULTS, ...config};
75
+ const base = {
76
+ cfg,
77
+ random: mulberry32(seed),
78
+ position: Math.floor(POSITIONS / 2),
79
+ picks: cfg.picks,
80
+ durability: cfg.durability,
81
+ score: 0,
82
+ opened: 0,
83
+ snapped: 0,
84
+ turns: 0,
85
+ last: null,
86
+ over: false,
87
+ };
88
+ return {...base, lock: newLock(base)};
89
+ }
90
+
91
+ /** Move the pick. The dial does not wrap: both ends are ends. */
92
+ export function setPosition(state, position) {
93
+ if (state.over) return state;
94
+ const clamped = Math.min(POSITIONS - 1, Math.max(0, Math.round(position)));
95
+ return clamped === state.position ? state : {...state, position: clamped};
96
+ }
97
+
98
+ export function nudge(state, delta) {
99
+ return setPosition(state, state.position + delta);
100
+ }
101
+
102
+ /**
103
+ * How far the cylinder turns at the current position, 0 to 1.
104
+ *
105
+ * Inside the tolerance it is 1: the lock opens. Outside, it falls away
106
+ * with distance, and that number is both the feedback the player reads
107
+ * and the inverse of the strain the pick takes.
108
+ */
109
+ export function give(state) {
110
+ const distance = Math.abs(state.position - state.lock.sweet);
111
+ if (distance <= state.lock.tolerance) return 1;
112
+ const reach = POSITIONS / 2;
113
+ return Math.max(0, 1 - (distance - state.lock.tolerance) / reach);
114
+ }
115
+
116
+ /**
117
+ * Turn the cylinder.
118
+ *
119
+ * Opens the lock, or costs the pick some life. A pick that runs out
120
+ * snaps, and the next one starts fresh on the same lock: the lock is
121
+ * not re-dealt, because a player who has narrowed it down to two
122
+ * positions should not lose that work to a broken pick.
123
+ */
124
+ export function turn(state) {
125
+ if (state.over) return state;
126
+
127
+ const turned = give(state);
128
+ const attempts = state.lock.attempts + 1;
129
+ const base = {...state, turns: state.turns + 1, lock: {...state.lock, attempts}};
130
+
131
+ if (turned >= 1) {
132
+ const bonus = Math.round(base.durability / base.cfg.durabilityBonusDivisor);
133
+ const opened = {
134
+ ...base,
135
+ score: base.score + base.cfg.pointsPerLock + bonus,
136
+ opened: base.opened + 1,
137
+ durability: base.cfg.durability,
138
+ last: {result: 'opened', points: base.cfg.pointsPerLock + bonus, attempts},
139
+ };
140
+ /* A fresh lock, and a fresh pick with it: the run ends on the
141
+ locks that beat you, not on wear from the ones you solved. */
142
+ return {...opened, lock: newLock(opened), position: Math.floor(POSITIONS / 2)};
143
+ }
144
+
145
+ const strain = Math.min(
146
+ base.cfg.maxStrain,
147
+ Math.round((1 - turned) ** 2 * 100 * base.cfg.strainScale / 2) + 4,
148
+ );
149
+ const durability = base.durability - strain;
150
+
151
+ if (durability > 0) {
152
+ return {...base, durability, last: {result: 'held', give: turned, attempts}};
153
+ }
154
+
155
+ const picks = base.picks - 1;
156
+ return {
157
+ ...base,
158
+ picks,
159
+ snapped: base.snapped + 1,
160
+ durability: base.cfg.durability,
161
+ last: {result: 'snapped', give: turned, attempts},
162
+ over: picks <= 0,
163
+ };
164
+ }
165
+
166
+ /** The line that goes on the game-over card and into the post. */
167
+ export function summarise(state, locale = 'en') {
168
+ const n = (v) => Number(v || 0).toLocaleString(locale);
169
+ return locale === 'nl'
170
+ ? `${n(state.opened)} sloten open · ${n(state.turns)} pogingen`
171
+ : `${n(state.opened)} locks opened · ${n(state.turns)} turns`;
172
+ }
@@ -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
+ }