@conduction/docusaurus-preset 3.38.0 → 3.39.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.
Files changed (37) hide show
  1. package/MISSING_COMPONENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/no-icu-messages.test.js +57 -0
  4. package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
  5. package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
  6. package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
  7. package/src/components/BlueprintRush/engine.js +172 -0
  8. package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
  9. package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
  10. package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
  11. package/src/components/DeadlineDefender/engine.js +188 -0
  12. package/src/components/DetailHero/DetailHero.jsx +11 -4
  13. package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
  14. package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
  15. package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
  16. package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
  17. package/src/components/GameModal/GameModal.jsx +255 -50
  18. package/src/components/GameModal/GameModal.module.css +103 -0
  19. package/src/components/GameModal/__tests__/scores.test.js +123 -0
  20. package/src/components/GameModal/__tests__/share.test.js +83 -0
  21. package/src/components/GameModal/scores.js +149 -0
  22. package/src/components/GameModal/share.js +97 -0
  23. package/src/components/RecordRun/RecordRun.jsx +245 -0
  24. package/src/components/RecordRun/RecordRun.module.css +208 -0
  25. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  26. package/src/components/RecordRun/engine.js +180 -0
  27. package/src/components/StampRush/StampRush.jsx +232 -0
  28. package/src/components/StampRush/StampRush.module.css +188 -0
  29. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  30. package/src/components/StampRush/engine.js +185 -0
  31. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  32. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  33. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  34. package/src/components/index.js +5 -0
  35. package/src/data/app-downloads.js +21 -0
  36. package/src/index.js +10 -0
  37. package/src/theme/Footer/index.jsx +10 -1
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Record run — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Connext's game. A record travels down the stack, and the stack is
5
+ * where records get stuck: a format nothing can read, a permission
6
+ * nobody granted, a connector that is not there. Move the record out
7
+ * of the way of what would stop it, and pick up the apps that move it
8
+ * along.
9
+ *
10
+ * The board is three lanes and a queue of rows moving towards the
11
+ * record. That makes it the only one of the four games with a position
12
+ * to steer rather than a thing to choose, which is the point: three
13
+ * games about picking the right answer would be one game three times.
14
+ *
15
+ * The grid is the whole state, so a run is replayable from a seed and
16
+ * every rule below is a pure function of it.
17
+ */
18
+
19
+ export const LANES = 3;
20
+
21
+ /* What a row can hold in a lane. */
22
+ export const CLEAR = 'clear';
23
+ export const BLOCK = 'block';
24
+ export const APP = 'app';
25
+
26
+ /* The three ways a record gets stuck, and the apps that carry it on.
27
+ The component turns these into words; the engine only cares that a
28
+ block stops the record and an app is worth collecting. */
29
+ export const BLOCKS = ['format', 'permission', 'connector'];
30
+ export const APPS = ['register', 'catalogue', 'portal'];
31
+
32
+ export const DEFAULTS = {
33
+ lives: 3,
34
+ rows: 6,
35
+ /* How long a row takes to travel one step towards the record. Falls
36
+ with the score, down to a floor a person can still react inside. */
37
+ stepStartMs: 620,
38
+ stepFloorMs: 240,
39
+ rampPerPoint: 1.6,
40
+ pointsPerRow: 2,
41
+ pointsPerApp: 8,
42
+ /* Two lanes blocked at once is a dead end when the record is already
43
+ committed, so a row never carries more than one block. */
44
+ blockChance: 0.55,
45
+ appChance: 0.3,
46
+ };
47
+
48
+ function mulberry32(seed) {
49
+ let a = seed >>> 0;
50
+ return function random() {
51
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
52
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
53
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
54
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Build one row: at most one block, optionally one app, and never a
60
+ * board the record cannot get through.
61
+ */
62
+ function makeRow(state) {
63
+ const cells = Array.from({length: LANES}, () => ({kind: CLEAR}));
64
+
65
+ if (state.random() < state.cfg.blockChance) {
66
+ const lane = Math.floor(state.random() * LANES);
67
+ cells[lane] = {kind: BLOCK, what: BLOCKS[Math.floor(state.random() * BLOCKS.length)]};
68
+ }
69
+
70
+ if (state.random() < state.cfg.appChance) {
71
+ const free = cells.map((c, i) => (c.kind === CLEAR ? i : -1)).filter((i) => i >= 0);
72
+ if (free.length) {
73
+ const lane = free[Math.floor(state.random() * free.length)];
74
+ cells[lane] = {kind: APP, what: APPS[Math.floor(state.random() * APPS.length)]};
75
+ }
76
+ }
77
+
78
+ return {cells, id: `r${state.spawned}`};
79
+ }
80
+
81
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
82
+ const cfg = {...DEFAULTS, ...config};
83
+ let state = {
84
+ cfg,
85
+ random: mulberry32(seed),
86
+ lane: 1,
87
+ rows: [],
88
+ spawned: 0,
89
+ score: 0,
90
+ lives: cfg.lives,
91
+ collected: 0,
92
+ blocked: 0,
93
+ travelled: 0,
94
+ nextStepAt: now + cfg.stepStartMs,
95
+ last: null,
96
+ over: false,
97
+ };
98
+ /* Start with a full board so the first rows are visible before they
99
+ matter, rather than the record meeting a row it never saw. */
100
+ for (let i = 0; i < cfg.rows; i++) {
101
+ state = {...state, rows: [...state.rows, makeRow(state)], spawned: state.spawned + 1};
102
+ }
103
+ return state;
104
+ }
105
+
106
+ /** How long a row currently takes to advance one step. */
107
+ export function stepMs(state) {
108
+ const {cfg, score} = state;
109
+ return Math.max(cfg.stepFloorMs, cfg.stepStartMs - score * cfg.rampPerPoint);
110
+ }
111
+
112
+ /** Move the record. Lanes do not wrap: the edges are edges. */
113
+ export function move(state, delta) {
114
+ if (state.over) return state;
115
+ const lane = Math.min(LANES - 1, Math.max(0, state.lane + delta));
116
+ return lane === state.lane ? state : {...state, lane};
117
+ }
118
+
119
+ export function moveTo(state, lane) {
120
+ if (state.over) return state;
121
+ if (!Number.isInteger(lane) || lane < 0 || lane >= LANES) return state;
122
+ return {...state, lane};
123
+ }
124
+
125
+ /**
126
+ * Advance the board if the step is due.
127
+ *
128
+ * The row nearest the record is the one it meets. A block in the
129
+ * record's lane costs a life; an app in it is collected; an empty lane
130
+ * is worth the small points that keep a careful player moving.
131
+ */
132
+ export function step(state, now) {
133
+ if (state.over || now < state.nextStepAt) return state;
134
+
135
+ const rows = [...state.rows];
136
+ const arriving = rows.pop();
137
+ let next = {...state, rows, travelled: state.travelled + 1};
138
+
139
+ const cell = arriving ? arriving.cells[state.lane] : {kind: CLEAR};
140
+
141
+ if (cell.kind === BLOCK) {
142
+ const lives = next.lives - 1;
143
+ next = {
144
+ ...next,
145
+ lives,
146
+ blocked: next.blocked + 1,
147
+ last: {result: 'blocked', what: cell.what, at: now},
148
+ over: lives <= 0,
149
+ };
150
+ } else if (cell.kind === APP) {
151
+ next = {
152
+ ...next,
153
+ score: next.score + next.cfg.pointsPerApp,
154
+ collected: next.collected + 1,
155
+ last: {result: 'collected', what: cell.what, at: now},
156
+ };
157
+ } else {
158
+ next = {...next, score: next.score + next.cfg.pointsPerRow, last: {result: 'through', at: now}};
159
+ }
160
+
161
+ if (next.over) return next;
162
+
163
+ /* Refill from the far end, so the board is always full and the
164
+ player can read what is coming. */
165
+ const fresh = makeRow(next);
166
+ return {
167
+ ...next,
168
+ rows: [fresh, ...next.rows],
169
+ spawned: next.spawned + 1,
170
+ nextStepAt: now + stepMs(next),
171
+ };
172
+ }
173
+
174
+ /** The line that goes on the game-over card and into the post. */
175
+ export function summarise(state, locale = 'en') {
176
+ const n = (v) => Number(v || 0).toLocaleString(locale);
177
+ return locale === 'nl'
178
+ ? `${n(state.travelled)} stappen · ${n(state.collected)} apps onderweg`
179
+ : `${n(state.travelled)} hops · ${n(state.collected)} apps picked up`;
180
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * <StampRush />
3
+ *
4
+ * Decidiq's mini-game. Decisions land on the desk faster than you can
5
+ * read them; adopt the ones that carry quorum, hold back the ones that
6
+ * do not. Stamping a decision that has no quorum, or one you should
7
+ * have declared an interest in, costs a life. Holding one back costs
8
+ * nothing, which is the only way a game can reward restraint.
9
+ *
10
+ * The rules live in ./engine.js with no DOM and no clock, so they are
11
+ * tested rather than observed. This file owns the clock, the keyboard
12
+ * and the paint.
13
+ *
14
+ * Usage on a product page:
15
+ *
16
+ * <StampRush />
17
+ *
18
+ * On game over it fires the same `connext:gameend` event every other
19
+ * mini-game fires, so the shared <GameModal/> records the score and
20
+ * offers to post it, and it listens for `connext:gamereplay` so the
21
+ * dialog's "Play again" restarts it in place.
22
+ *
23
+ * Accessibility: every desk slot is a real button, reachable by tab and
24
+ * by the number keys 1 to 6. Score, lives and each decision's state are
25
+ * announced as text, never by colour alone. Nothing moves until the
26
+ * player starts the game, so `prefers-reduced-motion` only quiets the
27
+ * card entrance and the stamp flash.
28
+ */
29
+
30
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
31
+ import {translate} from '@docusaurus/Translate';
32
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
33
+ import {createGame, step, stamp, summarise, SLOTS, READY, NO_QUORUM, CONFLICT} from './engine';
34
+ import styles from './StampRush.module.css';
35
+
36
+ const GAME_ID = 'stamp-rush';
37
+ const TICK_MS = 100;
38
+
39
+ function cardCopy(card) {
40
+ if (!card) return null;
41
+ if (card.kind === READY) {
42
+ return {
43
+ title: translate({id: 'preset.stampRush.card.ready.title', message: 'Ready to adopt', description: 'Stamp-rush card that the player should stamp'}),
44
+ note: translate(
45
+ {id: 'preset.stampRush.card.ready.note', message: 'Quorum {have} of {need}', description: 'Quorum line on a stamp-rush card. {have} present, {need} required.'},
46
+ {have: card.quorum.have, need: card.quorum.need},
47
+ ),
48
+ };
49
+ }
50
+ if (card.kind === NO_QUORUM) {
51
+ return {
52
+ title: translate({id: 'preset.stampRush.card.noQuorum.title', message: 'No quorum', description: 'Stamp-rush card the player must hold back because too few members are present'}),
53
+ note: translate(
54
+ {id: 'preset.stampRush.card.noQuorum.note', message: 'Quorum {have} of {need}', description: 'Quorum line on a stamp-rush card that lacks quorum.'},
55
+ {have: card.quorum.have, need: card.quorum.need},
56
+ ),
57
+ };
58
+ }
59
+ return {
60
+ title: translate({id: 'preset.stampRush.card.conflict.title', message: 'Interest declared', description: 'Stamp-rush card the player must hold back because of a declared conflict of interest'}),
61
+ note: translate({id: 'preset.stampRush.card.conflict.note', message: 'You may not vote', description: 'Second line on the conflict-of-interest card'}),
62
+ };
63
+ }
64
+
65
+ export default function StampRush({className}) {
66
+ const {i18n} = useDocusaurusContext();
67
+ const locale = (i18n && i18n.currentLocale) || 'en';
68
+
69
+ const [game, setGame] = useState(null);
70
+ const [flash, setFlash] = useState(null);
71
+ /* The run is kept in a ref as well, because the tick and the
72
+ game-over dispatch both read it outside React's render cycle. */
73
+ const gameRef = useRef(null);
74
+ const startedAtRef = useRef(0);
75
+ const endedRef = useRef(false);
76
+
77
+ const running = Boolean(game) && !game.over;
78
+
79
+ const begin = useCallback(() => {
80
+ endedRef.current = false;
81
+ startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
82
+ const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
83
+ gameRef.current = fresh;
84
+ setGame(fresh);
85
+ setFlash(null);
86
+ }, []);
87
+
88
+ /* The clock. One interval for the whole board: cards expire and
89
+ spawn on the same tick, so nothing can drift apart. */
90
+ useEffect(() => {
91
+ if (!running) return undefined;
92
+ const id = setInterval(() => {
93
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
94
+ const next = step(gameRef.current, now);
95
+ gameRef.current = next;
96
+ setGame(next);
97
+ }, TICK_MS);
98
+ return () => clearInterval(id);
99
+ }, [running]);
100
+
101
+ /* Game over: tell the shared dialog once, with a line it can post. */
102
+ useEffect(() => {
103
+ if (!game || !game.over || endedRef.current) return;
104
+ endedRef.current = true;
105
+ if (typeof window === 'undefined') return;
106
+ window.dispatchEvent(new CustomEvent('connext:gameend', {
107
+ detail: {
108
+ id: GAME_ID,
109
+ won: false,
110
+ score: game.score,
111
+ summary: summarise(game, locale),
112
+ title: translate({id: 'preset.stampRush.over.title', message: 'The meeting ran out of patience.', description: 'Headline on the game-over dialog after a stamp-rush run'}),
113
+ subtitle: translate({id: 'preset.stampRush.over.subtitle', message: 'Three bad stamps and the chair takes the pen back.', description: 'Subtitle on the game-over dialog after a stamp-rush run'}),
114
+ },
115
+ }));
116
+ }, [game, locale]);
117
+
118
+ /* "Play again" in the dialog restarts this game in place. */
119
+ useEffect(() => {
120
+ if (typeof window === 'undefined') return undefined;
121
+ const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
122
+ window.addEventListener('connext:gamereplay', onReplay);
123
+ return () => window.removeEventListener('connext:gamereplay', onReplay);
124
+ }, [begin]);
125
+
126
+ const hit = useCallback((slot) => {
127
+ if (!gameRef.current || gameRef.current.over) return;
128
+ const card = gameRef.current.slots[slot];
129
+ if (!card) return;
130
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
131
+ const next = stamp(gameRef.current, slot, now);
132
+ gameRef.current = next;
133
+ setGame(next);
134
+ setFlash({slot, good: card.kind === READY, at: now});
135
+ }, []);
136
+
137
+ /* Number keys 1 to 6, so the game is playable without a mouse and
138
+ fast enough to be worth playing that way. */
139
+ useEffect(() => {
140
+ if (!running || typeof window === 'undefined') return undefined;
141
+ const onKey = (e) => {
142
+ const n = Number(e.key);
143
+ if (Number.isInteger(n) && n >= 1 && n <= SLOTS) {
144
+ e.preventDefault();
145
+ hit(n - 1);
146
+ }
147
+ };
148
+ window.addEventListener('keydown', onKey);
149
+ return () => window.removeEventListener('keydown', onKey);
150
+ }, [running, hit]);
151
+
152
+ const lives = game ? game.lives : 3;
153
+ const score = game ? game.score : 0;
154
+
155
+ return (
156
+ <section className={[styles.rush, className].filter(Boolean).join(' ')} aria-labelledby="stamp-rush-title">
157
+ <header className={styles.head}>
158
+ <div>
159
+ <p className={styles.eyebrow}>
160
+ {translate({id: 'preset.stampRush.eyebrow', message: 'Mini-game', description: 'Eyebrow above the stamp-rush game on a product page'})}
161
+ </p>
162
+ <h3 className={styles.title} id="stamp-rush-title">
163
+ {translate({id: 'preset.stampRush.title', message: 'Stamp rush', description: 'Name of the Decidiq mini-game'})}
164
+ </h3>
165
+ <p className={styles.lede}>
166
+ {translate({id: 'preset.stampRush.lede', message: 'Adopt what has quorum. Hold back what does not. Three bad stamps and the chair takes the pen back.', description: 'One-line explanation of the stamp-rush rules'})}
167
+ </p>
168
+ </div>
169
+ <div className={styles.hud} role="status" aria-live="polite">
170
+ <span className={styles.hudScore}>
171
+ {translate({id: 'preset.stampRush.hud.score', message: 'Score {score}', description: 'Score readout on the stamp-rush HUD'}, {score: Number(score).toLocaleString(locale)})}
172
+ </span>
173
+ <span className={styles.hudLives}>
174
+ {translate({id: 'preset.stampRush.hud.lives', message: 'Stamps left {lives}', description: 'Remaining-lives readout on the stamp-rush HUD'}, {lives})}
175
+ </span>
176
+ </div>
177
+ </header>
178
+
179
+ <div className={styles.desk}>
180
+ {Array.from({length: SLOTS}).map((_, i) => {
181
+ const card = game ? game.slots[i] : null;
182
+ const copy = cardCopy(card);
183
+ const isFlash = flash && flash.slot === i;
184
+ return (
185
+ <button
186
+ key={i}
187
+ type="button"
188
+ className={[
189
+ styles.slot,
190
+ card && styles.slotFull,
191
+ card && card.kind === READY && styles.slotReady,
192
+ card && card.kind !== READY && styles.slotHold,
193
+ isFlash && (flash.good ? styles.flashGood : styles.flashBad),
194
+ ].filter(Boolean).join(' ')}
195
+ onClick={() => hit(i)}
196
+ onAnimationEnd={() => setFlash((f) => (f && f.slot === i ? null : f))}
197
+ disabled={!running}
198
+ aria-label={copy
199
+ ? translate(
200
+ /* States the decision and nothing else. An
201
+ earlier version ended "Stamp it", which told a
202
+ screen-reader user to stamp the very cards the
203
+ game exists to hold back. */
204
+ {id: 'preset.stampRush.slot.full', message: 'Desk {n}: {state}, {note}', description: 'Accessible label for an occupied stamp-rush desk slot. {state} is the decision state, {note} its quorum line.'},
205
+ {n: i + 1, state: copy.title, note: copy.note},
206
+ )
207
+ : translate({id: 'preset.stampRush.slot.empty', message: 'Desk {n}: empty', description: 'Accessible label for an empty stamp-rush desk slot'}, {n: i + 1})}>
208
+ <span className={styles.slotIndex} aria-hidden="true">{i + 1}</span>
209
+ {copy && (
210
+ <span className={styles.card}>
211
+ <span className={styles.cardTitle}>{copy.title}</span>
212
+ <span className={styles.cardNote}>{copy.note}</span>
213
+ </span>
214
+ )}
215
+ </button>
216
+ );
217
+ })}
218
+ </div>
219
+
220
+ <footer className={styles.foot}>
221
+ <button type="button" className={styles.start} onClick={begin}>
222
+ {game
223
+ ? translate({id: 'preset.stampRush.restart', message: 'Restart', description: 'Button that restarts the stamp-rush game'})
224
+ : translate({id: 'preset.stampRush.start', message: 'Take the pen', description: 'Button that starts the stamp-rush game'})}
225
+ </button>
226
+ <p className={styles.hint}>
227
+ {translate({id: 'preset.stampRush.hint', message: 'Click a desk, or press its number. Adopting is worth more each time you get it right in a row.', description: 'Hint under the stamp-rush board explaining the controls and the streak bonus'})}
228
+ </p>
229
+ </footer>
230
+ </section>
231
+ );
232
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * <StampRush /> styles.
3
+ *
4
+ * Tokens only, and one orange accent: the card that must be held back.
5
+ * Every state is carried by text as well as by colour, because a
6
+ * player who cannot tell mint from terracotta still has to know which
7
+ * decision may be adopted.
8
+ */
9
+
10
+ .rush {
11
+ border: 1px solid var(--c-cobalt-100);
12
+ border-radius: var(--radius-lg);
13
+ background: white;
14
+ padding: clamp(16px, 3vw, 28px);
15
+ font-family: var(--conduction-typography-font-family-body);
16
+ }
17
+
18
+ .head {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ gap: 16px;
22
+ align-items: flex-start;
23
+ justify-content: space-between;
24
+ margin-bottom: 18px;
25
+ }
26
+
27
+ .eyebrow {
28
+ margin: 0 0 4px;
29
+ font-family: var(--conduction-typography-font-family-code);
30
+ font-size: 11px;
31
+ letter-spacing: 0.12em;
32
+ text-transform: uppercase;
33
+ color: var(--c-orange-knvb);
34
+ }
35
+
36
+ .title {
37
+ margin: 0 0 6px;
38
+ font-size: 20px;
39
+ font-weight: 700;
40
+ color: var(--c-cobalt-900);
41
+ }
42
+
43
+ .lede {
44
+ margin: 0;
45
+ max-width: 52ch;
46
+ font-size: 14px;
47
+ line-height: 1.5;
48
+ color: var(--c-cobalt-700);
49
+ }
50
+
51
+ .hud {
52
+ display: flex;
53
+ gap: 10px;
54
+ flex-wrap: wrap;
55
+ font-family: var(--conduction-typography-font-family-code);
56
+ font-size: 12px;
57
+ font-variant-numeric: tabular-nums;
58
+ }
59
+ .hudScore,
60
+ .hudLives {
61
+ padding: 6px 10px;
62
+ border-radius: var(--radius-pill);
63
+ background: var(--c-cobalt-50);
64
+ color: var(--c-cobalt-900);
65
+ white-space: nowrap;
66
+ }
67
+
68
+ /* Capped: across a full content column the six slots become
69
+ billboards, and a desk you cannot take in at a glance is not a game
70
+ you can play at speed. */
71
+ .desk {
72
+ display: grid;
73
+ grid-template-columns: repeat(3, 1fr);
74
+ gap: 10px;
75
+ max-width: 560px;
76
+ }
77
+ @media (max-width: 560px) {
78
+ .desk { grid-template-columns: repeat(2, 1fr); }
79
+ }
80
+
81
+ .slot {
82
+ position: relative;
83
+ aspect-ratio: 5 / 3;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ padding: 10px;
88
+ border: 1px dashed var(--c-cobalt-200);
89
+ border-radius: var(--radius-md);
90
+ background: var(--c-cobalt-50);
91
+ cursor: pointer;
92
+ font-family: inherit;
93
+ text-align: center;
94
+ transition: border-color 120ms ease, background 120ms ease;
95
+ }
96
+ .slot:disabled { cursor: default; opacity: 0.75; }
97
+ .slot:focus-visible {
98
+ outline: 2px solid var(--c-blue-cobalt);
99
+ outline-offset: 2px;
100
+ }
101
+
102
+ .slotIndex {
103
+ position: absolute;
104
+ top: 6px;
105
+ left: 8px;
106
+ font-family: var(--conduction-typography-font-family-code);
107
+ font-size: 10px;
108
+ color: var(--c-cobalt-300);
109
+ }
110
+
111
+ .slotFull {
112
+ border-style: solid;
113
+ background: white;
114
+ box-shadow: var(--shadow-3);
115
+ }
116
+ .slotReady { border-color: var(--c-mint-500); }
117
+ .slotHold { border-color: var(--c-orange-knvb); }
118
+
119
+ .card {
120
+ display: flex;
121
+ flex-direction: column;
122
+ gap: 4px;
123
+ }
124
+
125
+ .cardTitle {
126
+ font-size: 13px;
127
+ font-weight: 600;
128
+ color: var(--c-cobalt-900);
129
+ line-height: 1.25;
130
+ }
131
+
132
+ .cardNote {
133
+ font-family: var(--conduction-typography-font-family-code);
134
+ font-size: 11px;
135
+ color: var(--c-cobalt-700);
136
+ }
137
+
138
+ .foot {
139
+ display: flex;
140
+ flex-wrap: wrap;
141
+ align-items: center;
142
+ gap: 12px;
143
+ margin-top: 16px;
144
+ }
145
+
146
+ .start {
147
+ background: var(--c-blue-cobalt);
148
+ color: white;
149
+ border: 1px solid var(--c-blue-cobalt);
150
+ padding: 10px 18px;
151
+ border-radius: var(--radius-md);
152
+ font-family: inherit;
153
+ font-weight: 500;
154
+ font-size: 14px;
155
+ cursor: pointer;
156
+ transition: background 120ms ease;
157
+ }
158
+ .start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
159
+ .start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
160
+
161
+ .hint {
162
+ margin: 0;
163
+ font-size: 12px;
164
+ color: var(--c-cobalt-400);
165
+ max-width: 48ch;
166
+ }
167
+
168
+ /* The card arriving, and the answer to a stamp. Motion only: every
169
+ state above is already readable without it. */
170
+ @media (prefers-reduced-motion: no-preference) {
171
+ .slotFull .card { animation: srLand 160ms ease-out both; }
172
+ @keyframes srLand {
173
+ from { opacity: 0; transform: translateY(4px) scale(0.96); }
174
+ to { opacity: 1; transform: none; }
175
+ }
176
+
177
+ .flashGood { animation: srGood 260ms ease-out; }
178
+ .flashBad { animation: srBad 260ms ease-out; }
179
+ @keyframes srGood {
180
+ 0% { background: var(--c-mint-300); }
181
+ 100% { background: var(--c-cobalt-50); }
182
+ }
183
+ @keyframes srBad {
184
+ 0%, 100% { background: var(--c-cobalt-50); transform: translateX(0); }
185
+ 25% { background: var(--c-coral-300); transform: translateX(-3px); }
186
+ 75% { transform: translateX(3px); }
187
+ }
188
+ }