@conduction/docusaurus-preset 3.27.2 → 3.31.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 (38) hide show
  1. package/README.md +34 -0
  2. package/package.json +6 -5
  3. package/src/components/AiDisclosure/AiDisclosure.jsx +82 -0
  4. package/src/components/AiDisclosure/AiDisclosure.module.css +35 -0
  5. package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +127 -0
  6. package/src/components/AiDisclosure/__tests__/disclosure.test.js +218 -0
  7. package/src/components/AiDisclosure/disclosure.js +169 -0
  8. package/src/components/AppMock/AppMock.jsx +16 -0
  9. package/src/components/AppMock/variants/AppVersionsMock.jsx +83 -0
  10. package/src/components/AppMock/variants/DoriathMock.jsx +79 -0
  11. package/src/components/AppMock/variants/HermiqMock.jsx +71 -0
  12. package/src/components/AppMock/variants/HrmqMock.jsx +77 -0
  13. package/src/components/AppMock/variants/PlanixMock.jsx +80 -0
  14. package/src/components/AppMock/variants/PortaliqMock.jsx +63 -0
  15. package/src/components/AppMock/variants/ScholiqMock.jsx +74 -0
  16. package/src/components/AppMock/variants/ShillinqMock.jsx +90 -0
  17. package/src/components/CookieCli/CookieCli.jsx +197 -24
  18. package/src/components/CookieCli/CookieCli.module.css +139 -1
  19. package/src/components/CookieCli/shell.js +161 -0
  20. package/src/components/CookieCli/spaceInvaders.js +359 -0
  21. package/src/components/CookieCli/spaceInvaders.test.js +112 -0
  22. package/src/components/index.js +7 -0
  23. package/src/data/app-glyphs.json +4 -0
  24. package/src/theme/BlogPostItem/Content/index.jsx +43 -0
  25. package/src/theme/DocItem/Content/index.jsx +15 -0
  26. package/static/img/ai-disclosure/PROVENANCE.md +87 -0
  27. package/static/img/ai-disclosure/ai-black-transparent.svg +20 -0
  28. package/static/img/ai-disclosure/ai-black.svg +19 -0
  29. package/static/img/ai-disclosure/ai-generated-black-transparent.svg +31 -0
  30. package/static/img/ai-disclosure/ai-generated-black.svg +30 -0
  31. package/static/img/ai-disclosure/ai-generated-white-transparent.svg +32 -0
  32. package/static/img/ai-disclosure/ai-generated-white.svg +31 -0
  33. package/static/img/ai-disclosure/ai-modified-black-transparent.svg +30 -0
  34. package/static/img/ai-disclosure/ai-modified-black.svg +29 -0
  35. package/static/img/ai-disclosure/ai-modified-white-transparent.svg +31 -0
  36. package/static/img/ai-disclosure/ai-modified-white.svg +30 -0
  37. package/static/img/ai-disclosure/ai-white-transparent.svg +21 -0
  38. package/static/img/ai-disclosure/ai-white.svg +20 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The tiny shell behind the cookie CLI prompt.
3
+ *
4
+ * The panel has always *looked* like a terminal. This makes it behave like
5
+ * one for anyone who tries: `ls` lists a small fake filesystem, `cat` reads
6
+ * the files, and one of them is executable. Finding `game.exe` is the point;
7
+ * everything else exists so that finding it feels earned rather than
8
+ * announced.
9
+ *
10
+ * Ported from the kit specimen at preview/cookie-cli.html.
11
+ *
12
+ * Pure: commands take state and return output plus an optional effect, so
13
+ * the React component decides what actually happens. That keeps consent
14
+ * behaviour out of a file about pretending to be bash.
15
+ */
16
+
17
+ export const FS_FILES = [
18
+ {name: 'README.md', type: 'file', size: 421, exec: false},
19
+ {name: 'cookies.toml', type: 'file', size: 847, exec: false},
20
+ {name: 'game.exe', type: 'file', size: 6320, exec: true},
21
+ {name: 'index.html', type: 'file', size: 312, exec: false},
22
+ {name: 'preferences', type: 'dir', size: 64, exec: false},
23
+ {name: 'privacy.md', type: 'file', size: 198, exec: false},
24
+ {name: 'snake.txt', type: 'file', size: 142, exec: false},
25
+ {name: '.config', type: 'dir', size: 64, exec: false, hidden: true},
26
+ {name: '.bashrc', type: 'file', size: 88, exec: false, hidden: true},
27
+ ];
28
+
29
+ const FILE_CONTENTS = {
30
+ 'README.md': `# conduction.nl
31
+
32
+ This is the company website. We use cookies for:
33
+ - essential functions (always on)
34
+ - aggregate analytics
35
+ - your preferences
36
+
37
+ Pick what you're OK with above. Or hit a number key.
38
+
39
+ PS: there's a game.exe in here somewhere if you're bored.
40
+ Try \`ls\` to find it.`,
41
+
42
+ 'cookies.toml': `# cookies.toml — generated by cookie-consent
43
+ # https://conduction.nl/privacy
44
+
45
+ [essential]
46
+ required = true
47
+ description = "Session, CSRF token, language preference."
48
+
49
+ [analytics]
50
+ required = false
51
+ description = "Aggregate, anonymous."
52
+
53
+ [preferences]
54
+ required = false
55
+ description = "Theme, language, collapsed docs sections."`,
56
+
57
+ 'privacy.md': `# privacy
58
+
59
+ We do not sell, share, or rent your data.
60
+ The full policy lives at /privacy.`,
61
+
62
+ 'snake.txt': `snake.exe was removed in v2.
63
+ Management felt one game was enough.
64
+ Management was wrong, but game.exe survived.`,
65
+
66
+ '.bashrc': `# nothing interesting here. we checked.
67
+ alias ll='ls -la'`,
68
+
69
+ 'index.html': `<!doctype html>
70
+ <!-- you are, in a sense, already here -->`,
71
+ };
72
+
73
+ const HELP = `Available commands:
74
+ ls [-a] list files
75
+ cat <file> print a file
76
+ pwd print working directory
77
+ whoami you tell us
78
+ help this
79
+ clear clear the scrollback
80
+ ./game.exe ...try it
81
+
82
+ Number keys toggle cookie options. [A] accept all, [R] reject non-essential.`;
83
+
84
+ function pad(s, n) {
85
+ const str = String(s);
86
+ return str + ' '.repeat(Math.max(0, n - str.length));
87
+ }
88
+
89
+ /**
90
+ * Run one command line.
91
+ *
92
+ * @returns {{lines: Array<{text: string, tone?: string}>, effect?: string, clear?: boolean}}
93
+ * `effect` is 'game' (launch) or 'exit'; the caller performs it.
94
+ */
95
+ export function runCommand(raw) {
96
+ const input = raw.trim();
97
+ if (!input) return {lines: []};
98
+
99
+ const [cmd, ...args] = input.split(/\s+/);
100
+ const arg = args.filter((a) => !a.startsWith('-')).join(' ');
101
+ const flags = args.filter((a) => a.startsWith('-')).join('');
102
+
103
+ switch (cmd) {
104
+ case 'ls': {
105
+ const showAll = flags.includes('a');
106
+ const files = FS_FILES.filter((f) => showAll || !f.hidden);
107
+ return {
108
+ lines: files.map((f) => ({
109
+ text: `${pad(f.type === 'dir' ? 'drwxr-xr-x' : f.exec ? '-rwxr-xr-x' : '-rw-r--r--', 12)}${pad(f.size, 7)}${f.name}${f.type === 'dir' ? '/' : ''}`,
110
+ tone: f.exec ? 'ok' : f.type === 'dir' ? 'dir' : undefined,
111
+ })),
112
+ };
113
+ }
114
+
115
+ case 'cat': {
116
+ if (!arg) return {lines: [{text: 'cat: missing operand', tone: 'warn'}]};
117
+ const key = arg.replace(/^\.\//, '');
118
+ if (key === 'game.exe') {
119
+ return {lines: [{text: 'cat: game.exe: binary file. try ./game.exe instead.', tone: 'warn'}]};
120
+ }
121
+ const body = FILE_CONTENTS[key];
122
+ if (body === undefined) {
123
+ const known = FS_FILES.some((f) => f.name === key);
124
+ return {lines: [{text: known ? `cat: ${key}: Is a directory` : `cat: ${key}: No such file or directory`, tone: 'warn'}]};
125
+ }
126
+ return {lines: body.split('\n').map((text) => ({text}))};
127
+ }
128
+
129
+ case 'pwd':
130
+ return {lines: [{text: '/home/you'}]};
131
+
132
+ case 'whoami':
133
+ return {lines: [{text: 'you, presumably. we did not set a tracking cookie to find out.', tone: 'cmt'}]};
134
+
135
+ case 'help':
136
+ case '--help':
137
+ case 'man':
138
+ return {lines: HELP.split('\n').map((text) => ({text}))};
139
+
140
+ case 'clear':
141
+ return {lines: [], clear: true};
142
+
143
+ case 'exit':
144
+ case 'quit':
145
+ return {lines: [{text: '# there is no exit. pick your cookies.', tone: 'cmt'}], effect: 'exit'};
146
+
147
+ case 'game.exe':
148
+ case './game.exe':
149
+ case 'game':
150
+ return {lines: [], effect: 'game'};
151
+
152
+ case 'sudo':
153
+ return {lines: [{text: 'you are not in the sudoers file. this incident will not be logged, we do not log.', tone: 'warn'}]};
154
+
155
+ case 'rm':
156
+ return {lines: [{text: 'rm: permission denied. the cookies stay until you choose.', tone: 'warn'}]};
157
+
158
+ default:
159
+ return {lines: [{text: `${cmd}: command not found. try \`help\`.`, tone: 'warn'}]};
160
+ }
161
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Conduction Space Invaders — the game behind `game.exe` in the cookie CLI.
3
+ *
4
+ * Ported from the kit specimen at preview/cookie-cli.html, where this had
5
+ * lived since the component was designed. The React <CookieCli /> shipped
6
+ * without it for a while, which meant GameModal advertised a game
7
+ * ("Hex-vaders · cookie CLI") that no site actually had.
8
+ *
9
+ * Kept as a plain class rather than React state on purpose. It ticks every
10
+ * 90ms and repaints a 48x14 character grid, which is 672 cells per frame,
11
+ * eleven times a second. Reconciling that through React would do a great
12
+ * deal of work to arrive at the same string. The class builds the frame as
13
+ * HTML and hands it to the caller, which writes it to a ref.
14
+ *
15
+ * Cobalt UFOs, KNVB-orange lasers, per the kit.
16
+ *
17
+ * The engine owns no DOM and reads no globals. It reports frames through
18
+ * `onFrame` and asks to be dismissed through `onExit`, so the React
19
+ * component stays the only thing that touches the document.
20
+ */
21
+
22
+ const COLS = 48;
23
+ const ROWS = 14;
24
+
25
+ /* The event the shared GameModal listens for. Named `connext:` because the
26
+ modal has used that prefix since the ConNext era; it is a brand-internal
27
+ identifier kept for compatibility, not a live brand reference. */
28
+ const GAME_END_EVENT = 'connext:gameend';
29
+ const GAME_REPLAY_EVENT = 'connext:gamereplay';
30
+ const GAME_ID = 'invaders';
31
+
32
+ function escapeHtml(s) {
33
+ return String(s).replace(/[&<>"']/g, (c) => ({
34
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
35
+ }[c]));
36
+ }
37
+
38
+ export default class SpaceInvaders {
39
+ /**
40
+ * @param {object} opts
41
+ * @param {function} opts.onFrame - called with {screen, hud, footer} HTML each repaint
42
+ * @param {function} opts.onExit - called when the player quits (ESC / Q)
43
+ */
44
+ constructor({onFrame, onExit} = {}) {
45
+ this.onFrame = typeof onFrame === 'function' ? onFrame : () => {};
46
+ this.onExit = typeof onExit === 'function' ? onExit : () => {};
47
+
48
+ this.score = 0;
49
+ this.wave = 1;
50
+ this.hits = 0; // bombs that landed on you; costs nothing but pride
51
+ this.cleared = 0; // waves shot down
52
+ this.landed = 0; // waves that reached the floor
53
+ this.hitFlash = 0; // ticks left of the impact glyph
54
+ this.paused = false;
55
+ this.player = {x: Math.floor(COLS / 2) - 1};
56
+ this.lasers = [];
57
+ this.bombs = [];
58
+ this.ufos = [];
59
+ this.ufoDir = 1;
60
+ this.ufoSpeed = 9;
61
+ this.tickCount = 0;
62
+ this.graceTicks = 18; // ~1.6s before bombs start dropping
63
+
64
+ this.onReplay = (e) => {
65
+ if (e.detail && e.detail.id === GAME_ID) this.restart();
66
+ };
67
+ if (typeof window !== 'undefined') {
68
+ window.addEventListener(GAME_REPLAY_EVENT, this.onReplay);
69
+ }
70
+
71
+ this.spawnUfos();
72
+ this.interval = setInterval(() => this.tick(), 90);
73
+ this.render();
74
+ }
75
+
76
+ spawnUfos() {
77
+ this.ufos = [];
78
+ const rows = 3, cols = 6;
79
+ const spacing = 7;
80
+ const xStart = Math.floor((COLS - (cols - 1) * spacing - 3) / 2);
81
+ for (let r = 0; r < rows; r++) {
82
+ for (let c = 0; c < cols; c++) {
83
+ this.ufos.push({x: xStart + c * spacing, y: 1 + r * 2, alive: true, exploding: 0, row: r});
84
+ }
85
+ }
86
+ }
87
+
88
+ onKey(e) {
89
+ /* The old game-over branch is gone with game over itself. [R] used to
90
+ live only in that branch, which would have left it dead: reachable
91
+ by a key hint that never appeared, on a screen that never showed.
92
+ It now restarts at any time. */
93
+ switch (e.key) {
94
+ case 'r': case 'R':
95
+ this.restart(); e.preventDefault(); return;
96
+ default: break;
97
+ }
98
+ switch (e.key) {
99
+ case 'ArrowLeft': case 'a': case 'A':
100
+ this.player.x = Math.max(0, this.player.x - 2);
101
+ e.preventDefault(); break;
102
+ case 'ArrowRight': case 'd': case 'D':
103
+ this.player.x = Math.min(COLS - 3, this.player.x + 2);
104
+ e.preventDefault(); break;
105
+ case ' ': case 'ArrowUp': case 'w': case 'W':
106
+ this.fire(); e.preventDefault(); break;
107
+ case 'Escape': case 'q': case 'Q':
108
+ this.quit(); e.preventDefault(); break;
109
+ case 'p': case 'P':
110
+ this.paused = !this.paused; e.preventDefault(); this.render(); break;
111
+ default: break;
112
+ }
113
+ }
114
+
115
+ fire() {
116
+ if (this.lasers.length >= 3) return;
117
+ this.lasers.push({x: this.player.x + 1, y: ROWS - 2});
118
+ }
119
+
120
+ quit() {
121
+ this.stop();
122
+ this.onExit();
123
+ }
124
+
125
+ stop() {
126
+ clearInterval(this.interval);
127
+ this.interval = null;
128
+ if (typeof window !== 'undefined') {
129
+ window.removeEventListener(GAME_REPLAY_EVENT, this.onReplay);
130
+ }
131
+ }
132
+
133
+ restart() {
134
+ this.score = 0;
135
+ this.wave = 1;
136
+ this.hits = 0;
137
+ this.cleared = 0;
138
+ this.landed = 0;
139
+ this.hitFlash = 0;
140
+ this._endEmitted = false;
141
+ this.player = {x: Math.floor(COLS / 2) - 1};
142
+ this.lasers = [];
143
+ this.bombs = [];
144
+ this.ufoSpeed = 9;
145
+ this.tickCount = 0;
146
+ this.graceTicks = 18;
147
+ this.spawnUfos();
148
+ this.render();
149
+ }
150
+
151
+ /* Advance to a fresh wave, whether the last one was shot down or landed.
152
+ Clears bombs so the new wave does not open under incoming fire, and
153
+ restores the grace period so there is a beat before it resumes. */
154
+ nextWave() {
155
+ this.wave += 1;
156
+ this.spawnUfos();
157
+ this.ufoSpeed = Math.max(3, 9 - this.wave);
158
+ this.bombs = [];
159
+ this.graceTicks = 12;
160
+ this.render();
161
+ }
162
+
163
+ tick() {
164
+ if (this.paused) return;
165
+ this.tickCount++;
166
+
167
+ // lasers up every tick
168
+ this.lasers.forEach((l) => { l.y -= 1; });
169
+ this.lasers = this.lasers.filter((l) => l.y >= 0);
170
+
171
+ // bombs down every 2 ticks
172
+ if (this.tickCount % 2 === 0) {
173
+ this.bombs.forEach((b) => { b.y += 1; });
174
+ this.bombs = this.bombs.filter((b) => b.y < ROWS);
175
+ }
176
+
177
+ // UFOs march
178
+ if (this.tickCount % this.ufoSpeed === 0) {
179
+ const alive = this.ufos.filter((u) => u.alive);
180
+ if (alive.length) {
181
+ const minX = Math.min(...alive.map((u) => u.x));
182
+ const maxX = Math.max(...alive.map((u) => u.x));
183
+ if (this.ufoDir === 1 && maxX + 3 >= COLS) {
184
+ this.ufoDir = -1;
185
+ alive.forEach((u) => { u.y += 1; });
186
+ } else if (this.ufoDir === -1 && minX <= 0) {
187
+ this.ufoDir = 1;
188
+ alive.forEach((u) => { u.y += 1; });
189
+ } else {
190
+ alive.forEach((u) => { u.x += this.ufoDir; });
191
+ }
192
+ }
193
+ }
194
+
195
+ // random bombs, after a short grace period so the player can settle in
196
+ if (this.graceTicks > 0) {
197
+ this.graceTicks--;
198
+ } else {
199
+ const aliveUfos = this.ufos.filter((u) => u.alive);
200
+ const bombChance = 0.035 + (this.wave - 1) * 0.01;
201
+ if (aliveUfos.length && this.bombs.length < 4 && Math.random() < bombChance) {
202
+ const u = aliveUfos[Math.floor(Math.random() * aliveUfos.length)];
203
+ this.bombs.push({x: u.x + 1, y: u.y + 1});
204
+ }
205
+ }
206
+
207
+ // laser vs ufo
208
+ this.lasers.forEach((l) => {
209
+ this.ufos.forEach((u) => {
210
+ if (!u.alive) return;
211
+ if (l.y === u.y && l.x >= u.x && l.x <= u.x + 2) {
212
+ u.alive = false;
213
+ u.exploding = 2;
214
+ l.dead = true;
215
+ this.score += 10 * (3 - u.row);
216
+ const remaining = this.ufos.filter((uu) => uu.alive).length;
217
+ this.ufoSpeed = Math.max(2, 8 - Math.floor((18 - remaining) / 3));
218
+ }
219
+ });
220
+ });
221
+ this.lasers = this.lasers.filter((l) => !l.dead);
222
+
223
+ this.ufos.forEach((u) => { if (u.exploding > 0) u.exploding -= 1; });
224
+
225
+ /* Bombs vs player. You cannot die here.
226
+ This is an easter egg in a cookie banner, not an arcade cabinet:
227
+ ending someone's game with GAME OVER while they were only trying to
228
+ set a cookie preference is a bad trade. A hit still lands and still
229
+ reads as a hit, it just costs a moment of shield flash rather than a
230
+ life. Bombs stay dangerous-looking and become harmless. */
231
+ this.bombs.forEach((b) => {
232
+ if (b.y === ROWS - 1 && b.x >= this.player.x && b.x <= this.player.x + 2) {
233
+ b.dead = true;
234
+ this.hitFlash = 4; // ticks the player renders as an impact
235
+ this.hits += 1;
236
+ }
237
+ });
238
+ this.bombs = this.bombs.filter((b) => !b.dead);
239
+ if (this.hitFlash > 0) this.hitFlash -= 1;
240
+
241
+ /* UFOs reaching the bottom used to end the game outright, regardless of
242
+ lives. Now the wave simply lands and a fresh one spawns, so the run
243
+ continues. Without this the one unavoidable death would still be
244
+ reachable by standing still. */
245
+ if (this.ufos.some((u) => u.alive && u.y >= ROWS - 1)) {
246
+ this.landed += 1;
247
+ this.nextWave();
248
+ return;
249
+ }
250
+
251
+ // wave clear
252
+ if (this.ufos.every((u) => !u.alive && u.exploding === 0)) {
253
+ this.cleared += 1;
254
+ this.nextWave();
255
+
256
+ /* Tell the shared GameModal the game was found, so it records
257
+ discovery and score in its cross-site progress cookie.
258
+ This used to fire on game over. Since there is no game over any
259
+ more, clearing the first wave is the moment: without moving it the
260
+ event would never fire at all and the game would stay permanently
261
+ undiscovered in the modal, which is a silent regression rather than
262
+ a visible one. Once per run. */
263
+ if (!this._endEmitted && typeof window !== 'undefined') {
264
+ this._endEmitted = true;
265
+ window.dispatchEvent(new CustomEvent(GAME_END_EVENT, {
266
+ detail: {
267
+ id: GAME_ID,
268
+ won: true,
269
+ score: this.score,
270
+ summary: `${this.score} pts · wave ${this.wave}`,
271
+ },
272
+ }));
273
+ }
274
+ }
275
+
276
+ this.render();
277
+ }
278
+
279
+ render() {
280
+ const grid = [];
281
+ const meta = [];
282
+ for (let y = 0; y < ROWS; y++) {
283
+ grid.push(Array(COLS).fill(' '));
284
+ meta.push(Array(COLS).fill(''));
285
+ }
286
+ const set = (y, x, c, t) => {
287
+ if (y >= 0 && y < ROWS && x >= 0 && x < COLS) { grid[y][x] = c; meta[y][x] = t; }
288
+ };
289
+
290
+ this.ufos.forEach((u) => {
291
+ if (u.alive) {
292
+ set(u.y, u.x, '<', 'ufo'); set(u.y, u.x + 1, 'O', 'ufo'); set(u.y, u.x + 2, '>', 'ufo');
293
+ } else if (u.exploding > 0) {
294
+ set(u.y, u.x, '*', 'boom'); set(u.y, u.x + 1, '#', 'boom'); set(u.y, u.x + 2, '*', 'boom');
295
+ }
296
+ });
297
+
298
+ this.lasers.forEach((l) => set(l.y, l.x, '|', 'laser'));
299
+ this.bombs.forEach((b) => set(b.y, b.x, '*', 'bomb'));
300
+
301
+ /* The ship, or a brief impact burst when something just hit it. The
302
+ flash is the whole feedback for taking a hit now that it costs
303
+ nothing, so without it bombs would land with no effect at all and
304
+ the game would feel broken rather than forgiving. */
305
+ if (this.hitFlash > 0) {
306
+ set(ROWS - 1, this.player.x, '*', 'boom');
307
+ set(ROWS - 1, this.player.x + 1, '#', 'boom');
308
+ set(ROWS - 1, this.player.x + 2, '*', 'boom');
309
+ } else {
310
+ set(ROWS - 1, this.player.x, '<', 'player');
311
+ set(ROWS - 1, this.player.x + 1, 'A', 'player');
312
+ set(ROWS - 1, this.player.x + 2, '>', 'player');
313
+ }
314
+
315
+ /* Coalesce equal-styled runs into one span per run instead of one per
316
+ character. A row is 48 cells but usually only a handful of runs. */
317
+ let screen = '';
318
+ for (let y = 0; y < ROWS; y++) {
319
+ let line = '';
320
+ let runT = '', runS = '';
321
+ const flush = () => {
322
+ if (!runS) return;
323
+ line += runT ? `<span class="g-${runT}">${escapeHtml(runS)}</span>` : escapeHtml(runS);
324
+ runS = '';
325
+ };
326
+ for (let x = 0; x < COLS; x++) {
327
+ if (meta[y][x] === runT) runS += grid[y][x];
328
+ else { flush(); runT = meta[y][x]; runS = grid[y][x]; }
329
+ }
330
+ flush();
331
+ screen += `${line}\n`;
332
+ }
333
+
334
+ /* LIVES is gone, because there is nothing to count down. SHIELD says
335
+ the same thing the three little ships used to say (you are fine)
336
+ without implying a budget that can run out. HITS is there so the
337
+ bombs still mean something. */
338
+ const hud = `
339
+ <span class="hud-label">SCORE</span> <span class="hud-val g-laser">${String(this.score).padStart(4, '0')}</span>
340
+ <span class="hud-label">SHIELD</span> <span class="hud-val g-player">${escapeHtml('∞')}</span>
341
+ <span class="hud-label">WAVE</span> <span class="hud-val">${this.wave}</span>
342
+ <span class="hud-label">HITS</span> <span class="hud-val">${this.hits}</span>
343
+ <span style="margin-left:auto" class="hud-label">conduction · space-invaders.exe</span>
344
+ `;
345
+
346
+ let footer;
347
+ if (this.paused) {
348
+ footer = '<span class="warn">-- paused -- press [P] to resume</span>';
349
+ } else if (this.graceTicks > 0) {
350
+ footer = '<span class="ok">-- READY --</span> <span class="cmt">defend the cookies. fire orange lasers at the cobalt UFOs.</span>';
351
+ } else {
352
+ footer = '<span class="cmt"># [← →] move &nbsp; [space] fire orange laser &nbsp; [P] pause &nbsp; [ESC] exit</span>';
353
+ }
354
+
355
+ this.onFrame({screen, hud, footer});
356
+ }
357
+ }
358
+
359
+ export {COLS, ROWS, GAME_ID, GAME_END_EVENT};
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The one rule this game has: you cannot lose it.
3
+ *
4
+ * It is an easter egg inside a cookie banner. Someone opened that banner to
5
+ * set a preference, so ending their game with GAME OVER is a worse outcome
6
+ * than letting them play forever. Both original death paths are covered
7
+ * here, because they were separate: bombs draining lives, and the swarm
8
+ * reaching the floor, which ended the run outright no matter how many lives
9
+ * were left.
10
+ *
11
+ * These assertions were checked against the previous engine before being
12
+ * committed: nine of the ten fail on it. A test for the absence of
13
+ * behaviour is worth very little until you have watched it fail.
14
+ *
15
+ * Runs under `npm test` (node --test) with no DOM: the engine guards its
16
+ * window access, and stop() lets the tick be driven by hand instead of a
17
+ * timer.
18
+ */
19
+
20
+ import test from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import SpaceInvaders, {ROWS} from './spaceInvaders.js';
23
+
24
+ function newGame() {
25
+ const frames = [];
26
+ const game = new SpaceInvaders({
27
+ onFrame: (f) => frames.push(f),
28
+ onExit: () => assert.fail('the game exited on its own'),
29
+ });
30
+ game.stop(); // drive tick() manually
31
+ return {game, frames};
32
+ }
33
+
34
+ test('bombs landing on the player never end the run', () => {
35
+ const {game} = newGame();
36
+ game.graceTicks = 0;
37
+ /* Drop each bomb one row above the player and tick twice, which is how a
38
+ bomb actually arrives: it descends every other tick, and the collision
39
+ check runs in the same tick as the move. Injecting one directly onto
40
+ the player's row instead tests nothing, because on a move tick it
41
+ advances to ROWS and is filtered as off-screen before the check. */
42
+ for (let i = 0; i < 6; i++) {
43
+ game.bombs.push({x: game.player.x + 1, y: ROWS - 2});
44
+ game.tick();
45
+ game.tick();
46
+ }
47
+ assert.ok(game.hits >= 6, `all six bombs should register a hit, got ${game.hits}`);
48
+ assert.equal(game.lives, undefined, 'there should be no lives counter to run out');
49
+ assert.equal(game.gameOver, undefined, 'there should be no game-over flag');
50
+ });
51
+
52
+ test('the swarm reaching the floor rolls into a new wave', () => {
53
+ const {game} = newGame();
54
+ const before = game.wave;
55
+ game.ufos.forEach((u) => { u.alive = true; u.y = ROWS - 1; });
56
+ game.tick();
57
+ assert.equal(game.wave, before + 1, 'the wave should advance');
58
+ assert.equal(game.ufos.filter((u) => u.alive).length, 18, 'a fresh swarm should spawn');
59
+ assert.equal(game.landed, 1, 'the landing should be counted');
60
+ });
61
+
62
+ test('clearing a wave advances it', () => {
63
+ const {game} = newGame();
64
+ const before = game.wave;
65
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
66
+ game.tick();
67
+ assert.equal(game.wave, before + 1);
68
+ assert.equal(game.cleared, 1);
69
+ });
70
+
71
+ test('no frame ever renders a GAME OVER', () => {
72
+ const {game, frames} = newGame();
73
+ game.graceTicks = 0;
74
+ for (let i = 0; i < 40; i++) {
75
+ game.bombs.push({x: game.player.x + 1, y: ROWS - 1});
76
+ game.tick();
77
+ }
78
+ const bad = frames.filter((f) => /GAME OVER|YOU WIN/i.test(f.footer));
79
+ assert.equal(bad.length, 0, 'the footer should never announce an ending');
80
+ assert.ok(frames.at(-1).hud.includes('∞'), 'the HUD should show an infinite shield');
81
+ });
82
+
83
+ /* GameModal records discovery from `connext:gameend`. That used to fire on
84
+ game over, so removing game over would have silently stopped the game
85
+ from ever being marked as found. It now fires on the first wave cleared,
86
+ and this is the test that keeps it honest. */
87
+ test('clearing the first wave still reports the game as found', () => {
88
+ const events = [];
89
+ const priorWindow = globalThis.window;
90
+ globalThis.window = {
91
+ addEventListener() {}, removeEventListener() {},
92
+ dispatchEvent: (e) => { events.push(e); return true; },
93
+ };
94
+ globalThis.CustomEvent = globalThis.CustomEvent || class { constructor(type, init) { this.type = type; this.detail = init?.detail; } };
95
+ try {
96
+ const game = new SpaceInvaders({onFrame() {}, onExit() {}});
97
+ game.stop();
98
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
99
+ game.tick();
100
+ const end = events.filter((e) => e.type === 'connext:gameend');
101
+ assert.equal(end.length, 1, 'exactly one gameend should fire');
102
+ assert.equal(end[0].detail.id, 'invaders');
103
+ assert.equal(end[0].detail.won, true);
104
+
105
+ // and only once per run
106
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
107
+ game.tick();
108
+ assert.equal(events.filter((e) => e.type === 'connext:gameend').length, 1);
109
+ } finally {
110
+ globalThis.window = priorWindow;
111
+ }
112
+ });
@@ -142,3 +142,10 @@ export {default as Prerequisites, PrerequisiteItem} from './Prerequisites/Prereq
142
142
  export {default as Troubleshooting, TroubleshootingItem} from './Troubleshooting/Troubleshooting.jsx';
143
143
  export {default as NextSteps, NextStep} from './NextSteps/NextSteps.jsx';
144
144
  export {default as ContactCta} from './ContactCta/ContactCta.jsx';
145
+
146
+ /* AiDisclosure — EU Article-50 AI-content disclosure banner/mark.
147
+ Frontmatter-driven at the top of docs/blog pages (see the
148
+ DocItem/Content and BlogPostItem/Content theme swizzles); exported
149
+ here so an author can also drop it inline in MDX, e.g. beside a
150
+ single AI-generated figure within an otherwise human-written page. */
151
+ export {default as AiDisclosure} from './AiDisclosure/AiDisclosure.jsx';
@@ -19,6 +19,10 @@
19
19
  "inner": "<rect x=\"5\" y=\"11\" width=\"14\" height=\"10\" rx=\"2\" fill=\"currentColor\"/><path d=\"M8,11V7a4,4,0,0,1,8,0v4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/>",
20
20
  "viewBox": "0 0 24 24"
21
21
  },
22
+ "hermiq": {
23
+ "inner": "<path fill=\"currentColor\" d=\"M19,1L17.74,3.75L15,5L17.74,6.26L19,9L20.25,6.26L23,5L20.25,3.75M9,4L6.5,9.5L1,12L6.5,14.5L9,20L11.5,14.5L17,12L11.5,9.5M19,15L17.74,17.74L15,19L17.74,20.25L19,23L20.25,20.25L23,19L20.25,17.74\"/>",
24
+ "viewBox": "0 0 24 24"
25
+ },
22
26
  "financeq": {
23
27
  "inner": "<path fill=\"currentColor\" d=\"M6,16.5L3,19.44V11H6M11,14.66L9.43,13.32L8,14.64V7H11M16,13L13,16V3H16M18.81,12.81L17,11H22V16L20.21,14.21L13,21.36L9.53,18.34L5.75,22H3L9.47,15.66L13,18.64\"/>",
24
28
  "viewBox": "0 0 24 24"