@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.
- package/MISSING_COMPONENTS.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/no-icu-messages.test.js +57 -0
- package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
- package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
- package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
- package/src/components/BlueprintRush/engine.js +172 -0
- package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
- package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
- package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
- package/src/components/DeadlineDefender/engine.js +188 -0
- package/src/components/DetailHero/DetailHero.jsx +11 -4
- package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
- package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
- package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
- package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
- package/src/components/GameModal/GameModal.jsx +255 -50
- package/src/components/GameModal/GameModal.module.css +103 -0
- package/src/components/GameModal/__tests__/scores.test.js +123 -0
- package/src/components/GameModal/__tests__/share.test.js +83 -0
- package/src/components/GameModal/scores.js +149 -0
- package/src/components/GameModal/share.js +97 -0
- package/src/components/RecordRun/RecordRun.jsx +245 -0
- package/src/components/RecordRun/RecordRun.module.css +208 -0
- package/src/components/RecordRun/__tests__/engine.test.js +191 -0
- package/src/components/RecordRun/engine.js +180 -0
- package/src/components/StampRush/StampRush.jsx +232 -0
- package/src/components/StampRush/StampRush.module.css +188 -0
- package/src/components/StampRush/__tests__/engine.test.js +182 -0
- package/src/components/StampRush/engine.js +185 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
- package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
- package/src/components/index.js +5 -0
- package/src/data/app-downloads.js +21 -0
- package/src/index.js +10 -0
- 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
|
+
}
|