@conduction/docusaurus-preset 3.38.0 → 3.40.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 (41) 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/LockPick/LockPick.jsx +210 -0
  24. package/src/components/LockPick/LockPick.module.css +205 -0
  25. package/src/components/LockPick/__tests__/engine.test.js +193 -0
  26. package/src/components/LockPick/engine.js +172 -0
  27. package/src/components/RecordRun/RecordRun.jsx +245 -0
  28. package/src/components/RecordRun/RecordRun.module.css +208 -0
  29. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  30. package/src/components/RecordRun/engine.js +180 -0
  31. package/src/components/StampRush/StampRush.jsx +232 -0
  32. package/src/components/StampRush/StampRush.module.css +188 -0
  33. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  34. package/src/components/StampRush/engine.js +185 -0
  35. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  36. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  37. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  38. package/src/components/index.js +6 -0
  39. package/src/data/app-downloads.js +21 -0
  40. package/src/index.js +10 -0
  41. package/src/theme/Footer/index.jsx +10 -1
@@ -65,6 +65,7 @@ Most consume one or more primitives.
65
65
  - **BuildMock** — `BuildMock/BuildMock.jsx`. Looping "make it yours" animation: the orange Buildiq edit FAB is pressed, a component palette opens, and a widget is dragged into the page layout where it snaps in (~8s loop). `prefers-reduced-motion` / `running={false}` renders the static end state (component placed).
66
66
  - **KanbanMock** — `KanbanMock/KanbanMock.jsx`. Looping kanban drag for the pipelinq hero: a cursor lifts the top lead card, drags it to the next column, drops it flush into the column's card slot, and at the drop the column counts nudge (3→2, 2→3) and the per-lane € KPI tiles above the board shift the moved deal's value (~7s loop, clean reset). `prefers-reduced-motion` / `running={false}` renders the static board with the initial counts and totals.
67
67
  - **MockScene** — `MockScene/MockScene.jsx`. Free-form positioning stage for overlap shots of WidgetMocks + SidebarMocks (caller supplies canvas size and per-item x/y/z). The scene assembles on a loop: items land on a 220ms stagger, transform-only so the inline left/top positioning is never fought; `running={false}` / `prefers-reduced-motion` show the assembled scene.
68
+ - **ThemeSeamMock** — `ThemeSeamMock/ThemeSeamMock.jsx`. One AppMock frame rendered twice, the second inside a wrapper that redefines the palette and radius tokens. A stitched seam travels across the frame and the finish changes behind it (`--ts-dur`, 9s), then the loop hands the frame back. The themed layer is nothing but tokens, which is the claim Thematiq makes about a token set; the inner mocks run `running={false}` so their own loops do not compete with the seam. Themes: `lasuite`. `running={false}` / `prefers-reduced-motion` render the themed end state with the seam at rest.
68
69
  - **ExternalAppShelf** — `ExternalAppShelf/ExternalAppShelf.jsx`. Third-party-app shelf (Outlook-style, Mattermost, Keycloak, etc.) used on `/connext`. Specimen: `preview/components/external-app-shelf.html`.
69
70
  - **WidgetShelf** — `WidgetShelf/WidgetShelf.jsx`. Widget showcase with eyebrow + title + lede. Default is a slow single-row carousel (seamless duplicated track, pause on hover/focus, static wrapping grid under `prefers-reduced-motion`); loop duration derives from the widget count (6s per card, min 30s; `speed` overrides), with pause/play + prev/next controls (cobalt ghost, hidden under reduced motion). Widgets without an explicit `panel` get a deterministic auto-generated mini panel (KPI tile / bar chart / list rows / donut, hashed from the title). `carousel={false}` renders the N-column grid. Specimen: `preview/components/widget-shelf.html`.
70
71
  - **Showcase** — `Showcase/Showcase.jsx`. Multi-item collapsible showcase with screenshot + body. Used on `/commonground` for the "5-lagen model". Specimen: `preview/components/showcase.html`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.38.0",
3
+ "version": "3.40.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js",
6
6
  "test": "node --test"
@@ -0,0 +1,57 @@
1
+ /**
2
+ * no-icu-messages.test.js — no translatable string may use ICU plural
3
+ * or select syntax.
4
+ *
5
+ * Docusaurus's `translate()` and `<Translate>` substitute
6
+ * `{placeholder}` and nothing else. An ICU message therefore does not
7
+ * fail, does not warn, and does not fall back: it renders to the
8
+ * reader verbatim, braces and keywords and all, in every locale. It
9
+ * shipped that way in the game-over modal and was reported from the
10
+ * live site.
11
+ *
12
+ * The fix is two messages chosen in JS. This test is the reason nobody
13
+ * has to remember that.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const test = require('node:test');
19
+ const assert = require('node:assert/strict');
20
+ const path = require('node:path');
21
+ const fs = require('node:fs');
22
+
23
+ const SRC = path.resolve(__dirname, '..');
24
+ /* `{name, plural, ...}` / `{count, select, ...}`, the two ICU forms
25
+ that render raw. Deliberately loose: any argument name, any spacing. */
26
+ const ICU = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/;
27
+
28
+ function sourceFiles(dir) {
29
+ const out = [];
30
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
31
+ const full = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
34
+ out.push(...sourceFiles(full));
35
+ } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
36
+ out.push(full);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ test('no component ships an ICU plural or select message', () => {
43
+ const offenders = [];
44
+ for (const file of sourceFiles(SRC)) {
45
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
46
+ lines.forEach((line, i) => {
47
+ /* Skip the comment that explains the rule. */
48
+ if (/^\s*(\*|\/\/|\/\*)/.test(line)) return;
49
+ if (ICU.test(line)) offenders.push(`${path.relative(SRC, file)}:${i + 1}: ${line.trim()}`);
50
+ });
51
+ }
52
+ assert.deepEqual(
53
+ offenders,
54
+ [],
55
+ 'ICU syntax renders verbatim to the reader; use two messages picked in JS:\n' + offenders.join('\n'),
56
+ );
57
+ });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * <BlueprintRush />
3
+ *
4
+ * Buildiq's mini-game. A blueprint asks for the parts an app needs,
5
+ * in the reader's words rather than ours: where the records live, what
6
+ * one looks like, how people fill it in, who may see it. The tray
7
+ * offers those parts plus a few that belong to some other app. Fit the
8
+ * blueprint before the clock runs out; every finished app buys time.
9
+ *
10
+ * A puzzle rather than a reaction test, on purpose: the other two
11
+ * games are about speed, and a third one of those would be the same
12
+ * game a third time. Here the clock is the only pressure and the work
13
+ * is reading.
14
+ *
15
+ * The rules live in ./engine.js with no DOM and no clock.
16
+ *
17
+ * Usage on a product page:
18
+ *
19
+ * <BlueprintRush />
20
+ *
21
+ * Fires the shared `connext:gameend` event on game over, and listens
22
+ * for `connext:gamereplay`, like every other mini-game.
23
+ *
24
+ * Accessibility: the tray is a row of buttons, each labelled with the
25
+ * part it places, and the slots are a list that says what is still
26
+ * missing. Nothing depends on position or colour.
27
+ */
28
+
29
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
30
+ import {translate} from '@docusaurus/Translate';
31
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
32
+ import {createGame, step, place, timeLeft, summarise} from './engine';
33
+ import styles from './BlueprintRush.module.css';
34
+
35
+ const GAME_ID = 'blueprint-rush';
36
+ const TICK_MS = 100;
37
+
38
+ /* What the blueprint asks for, and what the tray offers, in the words
39
+ a business owner would use. The part key is never shown. */
40
+ function partCopy(part) {
41
+ switch (part) {
42
+ case 'register':
43
+ return {
44
+ slot: translate({id: 'preset.blueprintRush.slot.register', message: 'Somewhere to keep the records', description: 'Blueprint slot asking for a register'}),
45
+ tray: translate({id: 'preset.blueprintRush.tray.register', message: 'A register', description: 'Tray part: a register'}),
46
+ };
47
+ case 'schema':
48
+ return {
49
+ slot: translate({id: 'preset.blueprintRush.slot.schema', message: 'What one record looks like', description: 'Blueprint slot asking for a schema'}),
50
+ tray: translate({id: 'preset.blueprintRush.tray.schema', message: 'A set of fields', description: 'Tray part: a schema'}),
51
+ };
52
+ case 'form':
53
+ return {
54
+ slot: translate({id: 'preset.blueprintRush.slot.form', message: 'How people fill one in', description: 'Blueprint slot asking for a form'}),
55
+ tray: translate({id: 'preset.blueprintRush.tray.form', message: 'A form', description: 'Tray part: a form'}),
56
+ };
57
+ case 'view':
58
+ return {
59
+ slot: translate({id: 'preset.blueprintRush.slot.view', message: 'How people find one back', description: 'Blueprint slot asking for a list view'}),
60
+ tray: translate({id: 'preset.blueprintRush.tray.view', message: 'A list with search', description: 'Tray part: a list view'}),
61
+ };
62
+ case 'flow':
63
+ return {
64
+ slot: translate({id: 'preset.blueprintRush.slot.flow', message: 'What happens after someone saves', description: 'Blueprint slot asking for a flow'}),
65
+ tray: translate({id: 'preset.blueprintRush.tray.flow', message: 'A flow', description: 'Tray part: a flow'}),
66
+ };
67
+ case 'permission':
68
+ return {
69
+ slot: translate({id: 'preset.blueprintRush.slot.permission', message: 'Who is allowed to see it', description: 'Blueprint slot asking for permissions'}),
70
+ tray: translate({id: 'preset.blueprintRush.tray.permission', message: 'A group and its rights', description: 'Tray part: permissions'}),
71
+ };
72
+ case 'widget':
73
+ return {
74
+ slot: translate({id: 'preset.blueprintRush.slot.widget', message: 'What the manager sees on Monday', description: 'Blueprint slot asking for a dashboard widget'}),
75
+ tray: translate({id: 'preset.blueprintRush.tray.widget', message: 'A dashboard widget', description: 'Tray part: a dashboard widget'}),
76
+ };
77
+ default:
78
+ return {
79
+ slot: translate({id: 'preset.blueprintRush.slot.notification', message: 'Who hears about it', description: 'Blueprint slot asking for a notification'}),
80
+ tray: translate({id: 'preset.blueprintRush.tray.notification', message: 'A notification', description: 'Tray part: a notification'}),
81
+ };
82
+ }
83
+ }
84
+
85
+ export default function BlueprintRush({className}) {
86
+ const {i18n} = useDocusaurusContext();
87
+ const locale = (i18n && i18n.currentLocale) || 'en';
88
+
89
+ const [game, setGame] = useState(null);
90
+ const [seconds, setSeconds] = useState(0);
91
+ const gameRef = useRef(null);
92
+ const startedAtRef = useRef(0);
93
+ const endedRef = useRef(false);
94
+
95
+ const running = Boolean(game) && !game.over;
96
+
97
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
98
+
99
+ const begin = useCallback(() => {
100
+ endedRef.current = false;
101
+ startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
102
+ const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
103
+ gameRef.current = fresh;
104
+ setGame(fresh);
105
+ setSeconds(Math.ceil(timeLeft(fresh, 0) / 1000));
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ if (!running) return undefined;
110
+ const id = setInterval(() => {
111
+ const t = now();
112
+ const next = step(gameRef.current, t);
113
+ gameRef.current = next;
114
+ setGame(next);
115
+ setSeconds(Math.ceil(timeLeft(next, t) / 1000));
116
+ }, TICK_MS);
117
+ return () => clearInterval(id);
118
+ }, [running]);
119
+
120
+ useEffect(() => {
121
+ if (!game || !game.over || endedRef.current) return;
122
+ endedRef.current = true;
123
+ if (typeof window === 'undefined') return;
124
+ window.dispatchEvent(new CustomEvent('connext:gameend', {
125
+ detail: {
126
+ id: GAME_ID,
127
+ won: false,
128
+ score: game.score,
129
+ summary: summarise(game, locale),
130
+ title: translate({id: 'preset.blueprintRush.over.title', message: 'Time, and the blueprint is still open.', description: 'Headline on the game-over dialog after a blueprint-rush run'}),
131
+ subtitle: translate({id: 'preset.blueprintRush.over.subtitle', message: 'Every finished app bought you seconds. The wrong parts spent them.', description: 'Subtitle on the game-over dialog after a blueprint-rush run'}),
132
+ },
133
+ }));
134
+ }, [game, locale]);
135
+
136
+ useEffect(() => {
137
+ if (typeof window === 'undefined') return undefined;
138
+ const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
139
+ window.addEventListener('connext:gamereplay', onReplay);
140
+ return () => window.removeEventListener('connext:gamereplay', onReplay);
141
+ }, [begin]);
142
+
143
+ const put = useCallback((part) => {
144
+ if (!gameRef.current || gameRef.current.over) return;
145
+ const t = now();
146
+ const next = place(gameRef.current, part, t);
147
+ gameRef.current = next;
148
+ setGame(next);
149
+ setSeconds(Math.ceil(timeLeft(next, t) / 1000));
150
+ }, []);
151
+
152
+ const blueprint = game ? game.blueprint : null;
153
+ const filled = blueprint ? blueprint.filled : [];
154
+ const last = game && game.last ? game.last : null;
155
+
156
+ return (
157
+ <section className={[styles.br, className].filter(Boolean).join(' ')} aria-labelledby="blueprint-rush-title">
158
+ <header className={styles.head}>
159
+ <div>
160
+ <p className={styles.eyebrow}>
161
+ {translate({id: 'preset.blueprintRush.eyebrow', message: 'Mini-game', description: 'Eyebrow above the blueprint-rush game on a product page'})}
162
+ </p>
163
+ <h3 className={styles.title} id="blueprint-rush-title">
164
+ {translate({id: 'preset.blueprintRush.title', message: 'Blueprint rush', description: 'Name of the Buildiq mini-game'})}
165
+ </h3>
166
+ <p className={styles.lede}>
167
+ {translate({id: 'preset.blueprintRush.lede', message: 'Every app needs the same few parts. Pick them off the shelf before the clock runs out, and skip the ones that belong to somebody else’s app.', description: 'One-line explanation of the blueprint-rush rules'})}
168
+ </p>
169
+ </div>
170
+ <div className={styles.hud} role="status" aria-live="polite">
171
+ <span className={styles.hudPill}>
172
+ {translate({id: 'preset.blueprintRush.hud.score', message: 'Score {score}', description: 'Score readout on the blueprint-rush HUD'}, {score: Number(game ? game.score : 0).toLocaleString(locale)})}
173
+ </span>
174
+ <span className={[styles.hudPill, running && seconds <= 5 && styles.hudLow].filter(Boolean).join(' ')}>
175
+ {translate({id: 'preset.blueprintRush.hud.time', message: '{seconds}s left', description: 'Remaining-time readout on the blueprint-rush HUD'}, {seconds: running ? seconds : 0})}
176
+ </span>
177
+ </div>
178
+ </header>
179
+
180
+ <div className={styles.board}>
181
+ <ol className={styles.slots}>
182
+ {(blueprint ? blueprint.needed : []).map((part) => {
183
+ const done = filled.includes(part);
184
+ const copy = partCopy(part);
185
+ return (
186
+ <li key={part} className={[styles.slot, done && styles.slotDone].filter(Boolean).join(' ')}>
187
+ <span className={styles.slotMark} aria-hidden="true" />
188
+ <span className={styles.slotText}>{copy.slot}</span>
189
+ <span className={styles.slotState}>
190
+ {done
191
+ ? translate({id: 'preset.blueprintRush.slot.done', message: 'in place', description: 'State of a blueprint slot that has been filled'})
192
+ : translate({id: 'preset.blueprintRush.slot.open', message: 'open', description: 'State of a blueprint slot that is still empty'})}
193
+ </span>
194
+ </li>
195
+ );
196
+ })}
197
+ {!blueprint && (
198
+ <li className={styles.slot}>
199
+ <span className={styles.slotText}>
200
+ {translate({id: 'preset.blueprintRush.idle', message: 'An empty blueprint, and a shelf full of parts.', description: 'Placeholder on the blueprint before the game starts'})}
201
+ </span>
202
+ </li>
203
+ )}
204
+ </ol>
205
+
206
+ <div className={styles.tray}>
207
+ {(blueprint ? blueprint.tray : []).map((part) => {
208
+ const done = filled.includes(part);
209
+ return (
210
+ <button
211
+ key={part}
212
+ type="button"
213
+ className={[styles.part, done && styles.partUsed].filter(Boolean).join(' ')}
214
+ onClick={() => put(part)}
215
+ disabled={!running || done}>
216
+ {partCopy(part).tray}
217
+ </button>
218
+ );
219
+ })}
220
+ </div>
221
+ </div>
222
+
223
+ <footer className={styles.foot}>
224
+ <button type="button" className={styles.start} onClick={begin}>
225
+ {game
226
+ ? translate({id: 'preset.blueprintRush.restart', message: 'Restart', description: 'Button that restarts the blueprint-rush game'})
227
+ : translate({id: 'preset.blueprintRush.start', message: 'Open a blueprint', description: 'Button that starts the blueprint-rush game'})}
228
+ </button>
229
+ <p className={styles.hint} role="status" aria-live="polite">
230
+ {last && last.result === 'built' && translate({id: 'preset.blueprintRush.feedback.built', message: 'App built. That bought you seven seconds.', description: 'Feedback after completing a blueprint'})}
231
+ {last && last.result === 'wrong' && translate({id: 'preset.blueprintRush.feedback.wrong', message: 'That part belongs to another app. Two and a half seconds gone.', description: 'Feedback after picking a part the blueprint does not need'})}
232
+ {last && last.result === 'duplicate' && translate({id: 'preset.blueprintRush.feedback.duplicate', message: 'That one is already in place.', description: 'Feedback after picking a part that is already on the blueprint'})}
233
+ {(!last || last.result === 'placed' || last.result === 'timeout') && translate({id: 'preset.blueprintRush.hint', message: 'Read the blueprint, then take what it asks for. Each app in a row is worth more than the last.', description: 'Hint under the blueprint-rush board'})}
234
+ </p>
235
+ </footer>
236
+ </section>
237
+ );
238
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * <BlueprintRush /> styles.
3
+ *
4
+ * Tokens only. One accent: the clock in its last five seconds. The
5
+ * blueprint reads as a checklist because that is what it is, and the
6
+ * state of every slot is a word, not a colour.
7
+ */
8
+
9
+ .br {
10
+ border: 1px solid var(--c-cobalt-100);
11
+ border-radius: var(--radius-lg);
12
+ background: white;
13
+ padding: clamp(16px, 3vw, 28px);
14
+ font-family: var(--conduction-typography-font-family-body);
15
+ }
16
+
17
+ .head {
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ gap: 16px;
21
+ align-items: flex-start;
22
+ justify-content: space-between;
23
+ margin-bottom: 18px;
24
+ }
25
+
26
+ .eyebrow {
27
+ margin: 0 0 4px;
28
+ font-family: var(--conduction-typography-font-family-code);
29
+ font-size: 11px;
30
+ letter-spacing: 0.12em;
31
+ text-transform: uppercase;
32
+ color: var(--c-orange-knvb);
33
+ }
34
+
35
+ .title {
36
+ margin: 0 0 6px;
37
+ font-size: 20px;
38
+ font-weight: 700;
39
+ color: var(--c-cobalt-900);
40
+ }
41
+
42
+ .lede {
43
+ margin: 0;
44
+ max-width: 58ch;
45
+ font-size: 14px;
46
+ line-height: 1.5;
47
+ color: var(--c-cobalt-700);
48
+ }
49
+
50
+ .hud {
51
+ display: flex;
52
+ gap: 8px;
53
+ flex-wrap: wrap;
54
+ font-family: var(--conduction-typography-font-family-code);
55
+ font-size: 12px;
56
+ font-variant-numeric: tabular-nums;
57
+ }
58
+ .hudPill {
59
+ padding: 6px 10px;
60
+ border-radius: var(--radius-pill);
61
+ background: var(--c-cobalt-50);
62
+ color: var(--c-cobalt-900);
63
+ white-space: nowrap;
64
+ }
65
+ .hudLow {
66
+ background: var(--c-orange-knvb);
67
+ color: white;
68
+ }
69
+
70
+ .board {
71
+ display: grid;
72
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
73
+ gap: 20px;
74
+ align-items: start;
75
+ max-width: 760px;
76
+ }
77
+ @media (max-width: 720px) {
78
+ .board { grid-template-columns: 1fr; }
79
+ }
80
+
81
+ .slots {
82
+ list-style: none;
83
+ margin: 0;
84
+ padding: 14px;
85
+ border: 1px dashed var(--c-cobalt-200);
86
+ border-radius: var(--radius-md);
87
+ background: var(--c-cobalt-50);
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 8px;
91
+ }
92
+
93
+ .slot {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 10px;
97
+ font-size: 14px;
98
+ color: var(--c-cobalt-700);
99
+ line-height: 1.35;
100
+ }
101
+
102
+ .slotMark {
103
+ width: 10px;
104
+ height: 12px;
105
+ flex-shrink: 0;
106
+ clip-path: var(--hex-pointy-top);
107
+ background: var(--c-cobalt-300);
108
+ }
109
+ .slotDone .slotMark { background: var(--c-mint-500); }
110
+
111
+ .slotText { flex: 1; }
112
+ .slotDone .slotText { color: var(--c-cobalt-900); }
113
+
114
+ .slotState {
115
+ font-family: var(--conduction-typography-font-family-code);
116
+ font-size: 11px;
117
+ color: var(--c-cobalt-400);
118
+ white-space: nowrap;
119
+ }
120
+ .slotDone .slotState { color: var(--c-mint-500); }
121
+
122
+ .tray {
123
+ display: flex;
124
+ flex-wrap: wrap;
125
+ gap: 8px;
126
+ align-content: flex-start;
127
+ }
128
+
129
+ .part {
130
+ padding: 10px 14px;
131
+ border: 1px solid var(--c-cobalt-200);
132
+ border-radius: var(--radius-pill);
133
+ background: white;
134
+ font-family: inherit;
135
+ font-size: 13px;
136
+ color: var(--c-cobalt-900);
137
+ cursor: pointer;
138
+ transition: border-color 120ms ease, background 120ms ease;
139
+ }
140
+ .part:hover:not(:disabled) { border-color: var(--c-blue-cobalt); background: var(--c-cobalt-50); }
141
+ .part:disabled { cursor: default; }
142
+ .part:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
143
+
144
+ .partUsed {
145
+ opacity: 0.45;
146
+ text-decoration: line-through;
147
+ }
148
+
149
+ .foot {
150
+ display: flex;
151
+ flex-wrap: wrap;
152
+ align-items: center;
153
+ gap: 12px;
154
+ margin-top: 16px;
155
+ }
156
+
157
+ .start {
158
+ background: var(--c-blue-cobalt);
159
+ color: white;
160
+ border: 1px solid var(--c-blue-cobalt);
161
+ padding: 10px 18px;
162
+ border-radius: var(--radius-md);
163
+ font-family: inherit;
164
+ font-weight: 500;
165
+ font-size: 14px;
166
+ cursor: pointer;
167
+ transition: background 120ms ease;
168
+ }
169
+ .start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
170
+ .start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
171
+
172
+ .hint {
173
+ margin: 0;
174
+ font-size: 12px;
175
+ color: var(--c-cobalt-400);
176
+ max-width: 52ch;
177
+ min-height: 1.5em;
178
+ }
179
+
180
+ @media (prefers-reduced-motion: no-preference) {
181
+ .slotDone .slotMark { animation: brSet 200ms ease-out; }
182
+ @keyframes brSet {
183
+ from { transform: scale(0.4); }
184
+ to { transform: scale(1); }
185
+ }
186
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * engine.test.js — the blueprint-rush rules.
3
+ *
4
+ * The one that matters: the tray must always contain every part the
5
+ * blueprint asks for. A deal that is missing one is unwinnable, and an
6
+ * unwinnable board in a timed game reads to the player as the game
7
+ * being broken, which it would be.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+
15
+ const {createGame, step, place, timeLeft, summarise, PARTS, DEFAULTS} = require('../engine.js');
16
+
17
+ /** Complete the current blueprint correctly. */
18
+ function build(state, now = 0) {
19
+ let s = state;
20
+ for (const part of [...s.blueprint.needed]) s = place(s, part, now);
21
+ return s;
22
+ }
23
+
24
+ test('a game opens with a blueprint, a tray and a clock', () => {
25
+ const s = createGame({seed: 1, now: 0});
26
+ assert.equal(s.blueprint.needed.length, DEFAULTS.slots);
27
+ assert.equal(s.blueprint.filled.length, 0);
28
+ assert.equal(timeLeft(s, 0), DEFAULTS.startMs);
29
+ assert.equal(s.over, false);
30
+ });
31
+
32
+ test('the tray always holds every part the blueprint asks for, plus the distractors', () => {
33
+ /* Twenty deals, because a shuffle bug shows up as one bad board in
34
+ a handful, not as every board. */
35
+ for (let seed = 1; seed <= 20; seed++) {
36
+ let s = createGame({seed, now: 0});
37
+ for (let round = 0; round < 3; round++) {
38
+ const {needed, tray} = s.blueprint;
39
+ for (const part of needed) {
40
+ assert.ok(tray.includes(part), `seed ${seed}: tray cannot complete the blueprint`);
41
+ }
42
+ assert.equal(tray.length, DEFAULTS.slots + DEFAULTS.distractors);
43
+ assert.equal(new Set(tray).size, tray.length, 'a part appears twice in the tray');
44
+ for (const part of tray) assert.ok(PARTS.includes(part), `unknown part ${part}`);
45
+ s = build(s, 100 * round);
46
+ }
47
+ }
48
+ });
49
+
50
+ test('placing a needed part fills its slot without scoring yet', () => {
51
+ let s = createGame({seed: 3, now: 0});
52
+ const first = s.blueprint.needed[0];
53
+ s = place(s, first, 10);
54
+ assert.deepEqual(s.blueprint.filled, [first]);
55
+ assert.equal(s.score, 0);
56
+ assert.equal(s.built, 0);
57
+ });
58
+
59
+ test('completing a blueprint scores, buys time and deals the next one', () => {
60
+ let s = createGame({seed: 4, now: 0});
61
+ const before = timeLeft(s, 0);
62
+ const firstNeeded = [...s.blueprint.needed];
63
+ s = build(s, 0);
64
+ assert.equal(s.built, 1);
65
+ assert.equal(s.score, DEFAULTS.pointsPerApp);
66
+ assert.equal(timeLeft(s, 0), before + DEFAULTS.bonusMs);
67
+ assert.equal(s.blueprint.filled.length, 0, 'the next blueprint started already filled');
68
+ assert.equal(s.blueprint.needed.length, DEFAULTS.slots);
69
+ assert.notDeepEqual(s.blueprint.needed, firstNeeded, 'the same blueprint was dealt twice in a row');
70
+ });
71
+
72
+ test('a streak of finished apps pays more each time', () => {
73
+ let s = createGame({seed: 5, now: 0});
74
+ s = build(s, 0);
75
+ const first = s.score;
76
+ s = build(s, 10);
77
+ assert.equal(s.score - first, DEFAULTS.pointsPerApp + DEFAULTS.comboBonus);
78
+ assert.equal(s.bestCombo, 2);
79
+ });
80
+
81
+ test('a part from some other app costs seconds, not a life', () => {
82
+ let s = createGame({seed: 6, now: 0});
83
+ const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
84
+ const before = timeLeft(s, 0);
85
+ s = place(s, wrong, 20);
86
+ assert.equal(timeLeft(s, 0), before - DEFAULTS.penaltyMs);
87
+ assert.equal(s.wrong, 1);
88
+ assert.equal(s.over, false, 'a wrong part ended the run');
89
+ assert.equal(s.blueprint.filled.length, 0);
90
+ });
91
+
92
+ test('placing the same part twice is a mistake, and the slot stays filled once', () => {
93
+ let s = createGame({seed: 7, now: 0});
94
+ const part = s.blueprint.needed[0];
95
+ s = place(s, part, 10);
96
+ /* Compare the deadline, not the time left: the clock moves between
97
+ the two reads and would hide or invent a penalty of its own. */
98
+ const deadlineBefore = s.endsAt;
99
+ s = place(s, part, 20);
100
+ assert.deepEqual(s.blueprint.filled, [part]);
101
+ assert.equal(s.endsAt, deadlineBefore - DEFAULTS.penaltyMs);
102
+ assert.equal(s.last.result, 'duplicate');
103
+ });
104
+
105
+ test('a wrong part breaks the streak', () => {
106
+ let s = createGame({seed: 8, now: 0});
107
+ s = build(s, 0);
108
+ assert.equal(s.combo, 1);
109
+ const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
110
+ s = place(s, wrong, 10);
111
+ assert.equal(s.combo, 0);
112
+ assert.equal(s.bestCombo, 1, 'the best streak was forgotten');
113
+ });
114
+
115
+ test('the clock ends the run, and nothing scores afterwards', () => {
116
+ let s = createGame({seed: 9, now: 0});
117
+ s = step(s, DEFAULTS.startMs + 1);
118
+ assert.equal(s.over, true);
119
+ assert.equal(timeLeft(s, DEFAULTS.startMs + 1), 0);
120
+
121
+ const frozen = place(s, s.blueprint.needed[0], DEFAULTS.startMs + 10);
122
+ assert.equal(frozen.score, s.score);
123
+ assert.equal(frozen.built, s.built);
124
+ });
125
+
126
+ test('a penalty that empties the clock ends the run there and then', () => {
127
+ let s = createGame({seed: 10, now: 0, config: {startMs: 2000}});
128
+ const wrong = s.blueprint.tray.find((p) => !s.blueprint.needed.includes(p));
129
+ s = place(s, wrong, 100);
130
+ assert.equal(s.over, true, 'the run kept going on an empty clock');
131
+ assert.equal(timeLeft(s, 100), 0);
132
+ });
133
+
134
+ test('the clock never reads negative', () => {
135
+ const s = createGame({seed: 11, now: 0});
136
+ assert.equal(timeLeft(s, DEFAULTS.startMs * 10), 0);
137
+ });
138
+
139
+ test('the same seed deals the same blueprints, a different one does not', () => {
140
+ const deals = (seed) => {
141
+ let s = createGame({seed, now: 0});
142
+ const out = [];
143
+ for (let i = 0; i < 4; i++) { out.push(s.blueprint.needed.join(',')); s = build(s, i); }
144
+ return out.join('|');
145
+ };
146
+ assert.equal(deals(42), deals(42));
147
+ assert.notEqual(deals(42), deals(43));
148
+ });
149
+
150
+ test('the summary reads as a sentence in both locales', () => {
151
+ const s = {built: 6, bestCombo: 3};
152
+ assert.match(summarise(s, 'en'), /6 apps built · streak 3/);
153
+ assert.match(summarise(s, 'nl'), /6 apps gebouwd · reeks 3/);
154
+ });