@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,74 @@
1
+ /**
2
+ * Scholiq abstract — learning environment.
3
+ *
4
+ * Inferred from the app role (courses, lessons, credentials): centre
5
+ * shows a course-card grid, each card carrying a progress bar, with a
6
+ * credential chip on the completed course. Left nav for courses /
7
+ * lessons / learners / certificates. Tone: forest — data you can
8
+ * trust a diploma to.
9
+ */
10
+
11
+ import React from 'react';
12
+ import styles from '../AppMock.module.css';
13
+
14
+ const PROGRESS = [
15
+ {ico: 'b', fill: '100%', done: true},
16
+ {ico: 'b', fill: '65%'},
17
+ {ico: null, fill: '40%'},
18
+ {ico: 'b', fill: '80%'},
19
+ {ico: 'e', fill: '25%'},
20
+ {ico: 'b', fill: '10%'},
21
+ ];
22
+
23
+ export default function ScholiqMock() {
24
+ return (
25
+ <>
26
+ <div className={styles.topbar}>
27
+ <div className={styles.logo}></div>
28
+ {Array.from({length: 14}).map((_, i) => <div key={i} className={styles.icon}></div>)}
29
+ <div className={styles.spacer}></div>
30
+ <div className={styles.bell}></div>
31
+ <div className={styles.avatar}></div>
32
+ </div>
33
+ <div className={[styles.body, styles.opencatalogi].filter(Boolean).join(' ')}>
34
+ <div className={styles.nav}>
35
+ <div className={styles.navHead}><div className={styles.h}></div><div className={styles.l}></div></div>
36
+ {[true, false, false, false].map((active, i) => (
37
+ <div key={i} className={[styles.item, active && styles.active].filter(Boolean).join(' ')}>
38
+ <div className={styles.ico}></div>
39
+ <div className={styles.l}></div>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ <div className={styles.col}>
44
+ <div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6}}>
45
+ <div className={styles.row + ' ' + styles.head} style={{width: '30%'}}></div>
46
+ <div className={styles.actions}>
47
+ <div className={styles.btn}></div>
48
+ </div>
49
+ </div>
50
+ {/* Course cards with progress bars */}
51
+ <div className={styles.grid}>
52
+ {PROGRESS.map(({ico, fill, done}, i) => (
53
+ <div key={i} className={[styles.card, ico && styles[ico]].filter(Boolean).join(' ')}>
54
+ <div className={styles.ico}></div>
55
+ <div className={styles.row}></div>
56
+ <div className={styles.row + ' ' + styles.short}></div>
57
+ <div style={{height: 4, background: 'var(--c-cobalt-100)', borderRadius: 2}}>
58
+ <div style={{width: fill, height: 4, background: 'var(--c-forest-500)', borderRadius: 2}}></div>
59
+ </div>
60
+ {done && (
61
+ /* Credential chip — course completed, certificate issued */
62
+ <div style={{display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 6px', borderRadius: 999, background: 'var(--c-forest-300)', alignSelf: 'flex-start'}}>
63
+ <div style={{width: 6, height: 7, clipPath: 'var(--hex-pointy-top)', background: 'var(--c-forest-500)'}}></div>
64
+ <div style={{height: 3, width: 22, background: 'var(--c-forest-500)', borderRadius: 1}}></div>
65
+ </div>
66
+ )}
67
+ </div>
68
+ ))}
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </>
73
+ );
74
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shillinq abstract — bookkeeping ledger.
3
+ *
4
+ * Inferred from the app role (invoices, VAT, bank reconciliation):
5
+ * a VAT/summary KPI strip over an invoice table — document icon,
6
+ * description, two amount columns (net / VAT), and a reconciliation
7
+ * tick column where a mint hex = matched against the bank and a
8
+ * muted pip = still open. Tone: terracotta — documents, human work.
9
+ */
10
+
11
+ import React from 'react';
12
+ import styles from '../AppMock.module.css';
13
+
14
+ const INVOICES = [
15
+ {amount: 26, vat: 16, matched: true},
16
+ {amount: 20, vat: 12, matched: true},
17
+ {amount: 30, vat: 18, matched: false},
18
+ {amount: 22, vat: 14, matched: true},
19
+ {amount: 18, vat: 10, matched: false},
20
+ ];
21
+
22
+ export default function ShillinqMock() {
23
+ return (
24
+ <>
25
+ <div className={styles.topbar}>
26
+ <div className={styles.logo}></div>
27
+ {Array.from({length: 14}).map((_, i) => <div key={i} className={styles.icon}></div>)}
28
+ <div className={styles.spacer}></div>
29
+ <div className={styles.bell}></div>
30
+ <div className={styles.avatar}></div>
31
+ </div>
32
+ <div className={[styles.body, styles.decidesk].filter(Boolean).join(' ')}>
33
+ <div className={styles.nav}>
34
+ <div className={styles.navHead}><div className={styles.h}></div><div className={styles.l}></div></div>
35
+ {[true, false, false, false, false].map((active, i) => (
36
+ <div key={i} className={[styles.item, active && styles.active].filter(Boolean).join(' ')}>
37
+ <div className={styles.ico}></div>
38
+ <div className={styles.l}></div>
39
+ </div>
40
+ ))}
41
+ </div>
42
+ <div className={styles.col}>
43
+ <div className={styles.head}>
44
+ <div className={styles.row + ' ' + styles.head} style={{width: '30%'}}></div>
45
+ <div className={styles.actions}>
46
+ <div className={styles.btn + ' ' + styles.ghost}></div>
47
+ <div className={styles.btn}></div>
48
+ </div>
49
+ </div>
50
+ {/* VAT / summary strip */}
51
+ <div className={styles.kpiRow}>
52
+ <div className={styles.kpi}>
53
+ <div className={styles.ico} style={{background: 'var(--c-terracotta-500)'}}></div>
54
+ <div className={styles.meta}><div className={styles.num}></div><div className={styles.label}></div></div>
55
+ </div>
56
+ <div className={styles.kpi}>
57
+ <div className={styles.ico} style={{background: 'var(--c-terracotta-300)'}}></div>
58
+ <div className={styles.meta}><div className={styles.num}></div><div className={styles.label}></div></div>
59
+ </div>
60
+ <div className={styles.kpi}>
61
+ <div className={styles.ico}></div>
62
+ <div className={styles.meta}><div className={styles.num}></div><div className={styles.label}></div></div>
63
+ </div>
64
+ </div>
65
+ {/* Invoice table: doc · description · net · VAT · reconciled */}
66
+ <div className={styles.panel} style={{flex: 1}}>
67
+ <div className={styles.head}><div className={styles.title}></div></div>
68
+ <div className={styles.stack}>
69
+ {INVOICES.map(({amount, vat, matched}, i) => (
70
+ <div key={i} className={styles.item}>
71
+ <div style={{width: 11, height: 13, clipPath: 'var(--hex-pointy-top)', background: 'var(--c-terracotta-300)', flexShrink: 0}}></div>
72
+ <div className={styles.lines}><div className={styles.l1}></div></div>
73
+ <div style={{width: amount, height: 4, background: 'var(--c-cobalt-700)', borderRadius: 1, flexShrink: 0}}></div>
74
+ <div style={{width: vat, height: 4, background: 'var(--c-cobalt-300)', borderRadius: 1, flexShrink: 0}}></div>
75
+ {/* Bank-reconciliation tick: mint hex = matched */}
76
+ <div style={{width: 9, height: 10, clipPath: 'var(--hex-pointy-top)', background: matched ? 'var(--c-mint-500)' : 'var(--c-cobalt-200)', flexShrink: 0}}></div>
77
+ </div>
78
+ ))}
79
+ </div>
80
+ {/* Totals line */}
81
+ <div style={{display: 'flex', justifyContent: 'flex-end', gap: 6, paddingTop: 4, borderTop: '1px solid var(--c-cobalt-100)'}}>
82
+ <div style={{width: 32, height: 6, background: 'var(--c-cobalt-900)', borderRadius: 1}}></div>
83
+ <div style={{width: 20, height: 6, background: 'var(--c-cobalt-400)', borderRadius: 1}}></div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </>
89
+ );
90
+ }
@@ -31,9 +31,11 @@
31
31
  * on every save.
32
32
  */
33
33
 
34
- import React, {useState, useEffect, useMemo, useCallback} from 'react';
34
+ import React, {useState, useEffect, useMemo, useCallback, useRef} from 'react';
35
35
  import useIsBrowser from '@docusaurus/useIsBrowser';
36
36
  import styles from './CookieCli.module.css';
37
+ import SpaceInvaders from './spaceInvaders';
38
+ import {runCommand} from './shell';
37
39
 
38
40
  const STORAGE_KEY = 'conduction:cookie-cli';
39
41
 
@@ -136,25 +138,127 @@ export default function CookieCli({
136
138
  save(minimal);
137
139
  }, [categories, save]);
138
140
 
139
- /* Bind keyboard shortcuts: A = accept all, S = save, R = reject,
140
- 1-9 = toggle category n. */
141
+ /* ---- the shell, and the game hiding in it ---- */
142
+
143
+ const [buffer, setBuffer] = useState('');
144
+ const [history, setHistory] = useState([]); // [{cmd, lines}]
145
+ const [mode, setMode] = useState('shell'); // 'shell' | 'game'
146
+ const gameRef = useRef(null);
147
+ const screenRef = useRef(null);
148
+ const hudRef = useRef(null);
149
+ const footerRef = useRef(null);
150
+ const promptRef = useRef(null);
151
+
152
+ const emit = useCallback((cmd, lines) => {
153
+ setHistory((prev) => [...prev, {cmd, lines}]);
154
+ }, []);
155
+
156
+ const exitGame = useCallback(() => {
157
+ if (gameRef.current) gameRef.current.stop();
158
+ gameRef.current = null;
159
+ setMode('shell');
160
+ emit(null, [{text: '# thanks for playing. back to your regularly scheduled cookies.', tone: 'cmt'}]);
161
+ }, [emit]);
162
+
163
+ const startGame = useCallback(() => {
164
+ emit(null, [
165
+ {text: 'Loading game.exe ...', tone: 'cmt'},
166
+ {text: '[OK] CONDUCTION SPACE INVADERS v0.1', tone: 'ok'},
167
+ ]);
168
+ setMode('game');
169
+ }, [emit]);
170
+
171
+ /* Construct the engine once the game surface is actually on screen.
172
+ Building it in the click handler would hand it refs that are still
173
+ null, because the panel it draws into has not rendered yet. */
174
+ useEffect(() => {
175
+ if (mode !== 'game' || gameRef.current) return undefined;
176
+ gameRef.current = new SpaceInvaders({
177
+ onFrame: ({screen, hud, footer}) => {
178
+ if (screenRef.current) screenRef.current.innerHTML = screen;
179
+ if (hudRef.current) hudRef.current.innerHTML = hud;
180
+ if (footerRef.current) footerRef.current.innerHTML = footer;
181
+ },
182
+ onExit: exitGame,
183
+ });
184
+ return () => {
185
+ if (gameRef.current) { gameRef.current.stop(); gameRef.current = null; }
186
+ };
187
+ }, [mode, exitGame]);
188
+
189
+ const submit = useCallback((line) => {
190
+ const result = runCommand(line);
191
+ if (result.clear) { setHistory([]); return; }
192
+ emit(line, result.lines);
193
+ if (result.effect === 'game') startGame();
194
+ }, [emit, startGame]);
195
+
196
+ /* One key handler for both modes.
197
+ In game mode every key belongs to the game. In shell mode the rule is
198
+ the kit specimen's, exactly: digits pick cookie options while the
199
+ prompt is empty, and everything else types.
200
+
201
+ There used to be [A] [S] [R] accelerators here, live whenever the
202
+ prompt was empty. They quietly ate the first letter of any command
203
+ starting with those letters, which made `sudo` and `rm` unreachable:
204
+ typing `sudo` answered the consent dialog and closed the banner
205
+ instead. A shell that silently refuses a third of the alphabet is
206
+ worse than one with no shortcuts, and the three actions are buttons
207
+ you can click or tab to. */
141
208
  useEffect(() => {
142
- if (!isBrowser || decided) return;
209
+ if (!isBrowser || decided) return undefined;
210
+
143
211
  function onKey(e) {
144
212
  if (e.metaKey || e.ctrlKey || e.altKey) return;
145
- const k = e.key.toLowerCase();
146
- if (k === 'a') { e.preventDefault(); acceptAll(); return; }
147
- if (k === 's') { e.preventDefault(); saveCurrent(); return; }
148
- if (k === 'r') { e.preventDefault(); rejectNonEssential(); return; }
149
- const idx = parseInt(e.key, 10);
150
- if (!Number.isNaN(idx) && idx >= 1 && idx <= categories.length) {
213
+
214
+ if (mode === 'game') {
215
+ if (gameRef.current) gameRef.current.onKey(e);
216
+ return;
217
+ }
218
+
219
+ if (e.key === 'Enter') {
151
220
  e.preventDefault();
152
- toggle(categories[idx - 1]);
221
+ const line = buffer;
222
+ setBuffer('');
223
+ submit(line);
224
+ return;
225
+ }
226
+ if (e.key === 'Backspace') {
227
+ e.preventDefault();
228
+ setBuffer((b) => b.slice(0, -1));
229
+ return;
230
+ }
231
+ if (e.key === 'Escape') {
232
+ e.preventDefault();
233
+ setBuffer('');
234
+ return;
235
+ }
236
+
237
+ /* Digits toggle only from an empty prompt, so a filename or a version
238
+ number can still be typed. */
239
+ if (buffer.length === 0) {
240
+ const idx = parseInt(e.key, 10);
241
+ if (!Number.isNaN(idx) && idx >= 1 && idx <= categories.length) {
242
+ e.preventDefault();
243
+ toggle(categories[idx - 1]);
244
+ return;
245
+ }
246
+ }
247
+
248
+ if (e.key.length === 1) {
249
+ e.preventDefault();
250
+ setBuffer((b) => b + e.key);
153
251
  }
154
252
  }
253
+
155
254
  window.addEventListener('keydown', onKey);
156
255
  return () => window.removeEventListener('keydown', onKey);
157
- }, [isBrowser, decided, acceptAll, saveCurrent, rejectNonEssential, toggle, categories]);
256
+ }, [isBrowser, decided, mode, buffer, submit, toggle, categories]);
257
+
258
+ /* Keep the prompt in view as output accumulates. */
259
+ useEffect(() => {
260
+ if (promptRef.current) promptRef.current.scrollIntoView({block: 'nearest'});
261
+ }, [history, mode]);
158
262
 
159
263
  /* Hide the banner once the user has made a decision; the prompt
160
264
  reappears via window.ConductionCookieCli.reset(). */
@@ -184,7 +288,7 @@ export default function CookieCli({
184
288
  </div>
185
289
  <p className={styles.comment}># Pick what you're OK with. Essential cookies are required.</p>
186
290
 
187
- <ul className={styles.opts}>
291
+ <ul className={styles.opts} style={mode === 'game' ? {display: 'none'} : undefined}>
188
292
  {categories.map((c, i) => {
189
293
  const on = !!selected[c.key] || !!c.required;
190
294
  return (
@@ -206,17 +310,86 @@ export default function CookieCli({
206
310
  })}
207
311
  </ul>
208
312
 
209
- <div className={styles.actions}>
210
- <button type="button" className={[styles.btn, styles.btnPrimary].join(' ')} onClick={acceptAll}>
211
- <span className={styles.btnKey}>A</span>Accept all
212
- </button>
213
- <button type="button" className={styles.btn} onClick={saveCurrent}>
214
- <span className={styles.btnKey}>S</span>Save selection
215
- </button>
216
- <button type="button" className={styles.btn} onClick={rejectNonEssential}>
217
- <span className={styles.btnKey}>R</span>Reject non-essential
218
- </button>
219
- </div>
313
+ {/* Scrollback. Command output is plain text with a tone class; the
314
+ game is the only thing that writes HTML, and it escapes its own
315
+ content in spaceInvaders.js. */}
316
+ {history.length > 0 && (
317
+ <div className={styles.scrollback}>
318
+ {history.map((block, i) => (
319
+ <div key={i} className={styles.outBlock}>
320
+ {block.cmd != null && (
321
+ <div className={styles.prompt}>
322
+ <span className={styles.user}>you</span>
323
+ <span className={styles.sigil}>@</span>
324
+ <span className={styles.host}>{siteHost}</span>
325
+ <span className={styles.sigil}>:</span>
326
+ <span className={styles.path}>~</span>
327
+ <span className={styles.sigil}>$</span>{' '}
328
+ <span className={styles.cmd}>{block.cmd}</span>
329
+ </div>
330
+ )}
331
+ {block.lines.length > 0 && (
332
+ <pre className={styles.out}>
333
+ {block.lines.map((l, j) => (
334
+ <div key={j} className={l.tone ? styles[`tone_${l.tone}`] : undefined}>{l.text}</div>
335
+ ))}
336
+ </pre>
337
+ )}
338
+ </div>
339
+ ))}
340
+ </div>
341
+ )}
342
+
343
+ {mode === 'game' ? (
344
+ <div className={styles.game}>
345
+ <div className={styles.gameHud} ref={hudRef} />
346
+ <pre className={styles.gameScreen} ref={screenRef} />
347
+ <div className={styles.gameFooter} ref={footerRef} />
348
+ </div>
349
+ ) : (
350
+ <>
351
+ {/* The live prompt. Typing is captured on window rather than in an
352
+ <input>, because the banner is not focused when it appears and
353
+ we do not want to steal focus from the page to make a joke
354
+ work. The hidden input keeps mobile keyboards reachable. */}
355
+ <div
356
+ className={styles.prompt}
357
+ ref={promptRef}
358
+ title="you can type here ;)"
359
+ style={{cursor: 'text'}}
360
+ onClick={() => { if (promptRef.current) promptRef.current.querySelector('input')?.focus(); }}
361
+ >
362
+ <span className={styles.user}>you</span>
363
+ <span className={styles.sigil}>@</span>
364
+ <span className={styles.host}>{siteHost}</span>
365
+ <span className={styles.sigil}>:</span>
366
+ <span className={styles.path}>~</span>
367
+ <span className={styles.sigil}>$</span>{' '}
368
+ <span className={styles.cmd}>{buffer}</span>
369
+ <span className={styles.cursor} aria-hidden="true" />
370
+ <input
371
+ type="text"
372
+ value={buffer}
373
+ onChange={() => {}}
374
+ className={styles.hiddenInput}
375
+ aria-label="Terminal input. Type help for commands."
376
+ tabIndex={-1}
377
+ />
378
+ </div>
379
+
380
+ <div className={styles.actions}>
381
+ <button type="button" className={[styles.btn, styles.btnPrimary].join(' ')} onClick={acceptAll}>
382
+ <span className={styles.btnKey}>A</span>Accept all
383
+ </button>
384
+ <button type="button" className={styles.btn} onClick={saveCurrent}>
385
+ <span className={styles.btnKey}>S</span>Save selection
386
+ </button>
387
+ <button type="button" className={styles.btn} onClick={rejectNonEssential}>
388
+ <span className={styles.btnKey}>R</span>Reject non-essential
389
+ </button>
390
+ </div>
391
+ </>
392
+ )}
220
393
  </div>
221
394
  </div>
222
395
  );
@@ -32,6 +32,15 @@
32
32
  width: calc(100% - 48px);
33
33
  max-width: 640px;
34
34
  overflow: hidden;
35
+
36
+ /* Cap the panel to the viewport and let the body scroll inside it.
37
+ Without this the panel grows with the shell scrollback and the game
38
+ surface until its title bar is pushed off the top of the screen: the
39
+ consent buttons then sit above the fold with no way to reach them.
40
+ Flex column so the title bar stays put and only .body scrolls. */
41
+ display: flex;
42
+ flex-direction: column;
43
+ max-height: calc(100vh - 48px);
35
44
  }
36
45
 
37
46
  .bar {
@@ -52,7 +61,14 @@
52
61
  .title { flex: 1; text-align: center; }
53
62
  .title b { color: var(--term-fg); }
54
63
 
55
- .body { padding: 18px 20px; }
64
+ /* min-height:0 is load-bearing: a flex child defaults to min-height:auto,
65
+ which refuses to shrink below its content, so overflow-y would never
66
+ engage and the panel would grow past the viewport anyway. */
67
+ .body {
68
+ padding: 18px 20px;
69
+ overflow-y: auto;
70
+ min-height: 0;
71
+ }
56
72
 
57
73
  .prompt { color: var(--term-fg); margin: 6px 0; }
58
74
  .user { color: var(--term-green); }
@@ -164,3 +180,125 @@
164
180
  .actions { flex-direction: column; }
165
181
  .btn { justify-content: center; }
166
182
  }
183
+
184
+ /* ============================================================
185
+ * The shell, and the game hiding in it
186
+ * ============================================================
187
+ * Colours mirror the kit specimen (preview/cookie-cli.html):
188
+ * cobalt UFOs, KNVB-orange lasers, green player.
189
+ *
190
+ * The game paints by writing HTML into a ref, so its class names are
191
+ * plain strings that never pass through the CSS-modules rewriter. They
192
+ * have to be :global() or they would style nothing at all, silently. */
193
+
194
+ .scrollback {
195
+ max-height: 40vh;
196
+ overflow-y: auto;
197
+ margin-bottom: 4px;
198
+ }
199
+
200
+ .outBlock { margin-bottom: 6px; }
201
+
202
+ .out {
203
+ margin: 4px 0;
204
+ font-family: inherit;
205
+ font-size: 12px;
206
+ line-height: 1.5;
207
+ color: var(--term-fg-dim);
208
+ white-space: pre-wrap;
209
+ background: none;
210
+ border: 0;
211
+ padding: 0;
212
+ }
213
+
214
+ .tone_ok { color: var(--term-green); }
215
+ .tone_warn { color: #F7C76F; }
216
+ .tone_cmt { color: var(--term-fg-dim); }
217
+ .tone_dir { color: var(--term-cobalt); }
218
+
219
+ /* Blinking block cursor after the typed text. */
220
+ .cursor {
221
+ display: inline-block;
222
+ width: 8px;
223
+ height: 14px;
224
+ margin-left: 2px;
225
+ vertical-align: text-bottom;
226
+ background: var(--term-green);
227
+ animation: cookieCliBlink 1s steps(2, start) infinite;
228
+ }
229
+
230
+ @keyframes cookieCliBlink {
231
+ to { visibility: hidden; }
232
+ }
233
+
234
+ /* Respect a reduced-motion preference: keep the cursor, drop the blink. */
235
+ @media (prefers-reduced-motion: reduce) {
236
+ .cursor { animation: none; }
237
+ }
238
+
239
+ /* Off-screen, not display:none — a hidden input still has to be reachable
240
+ for mobile keyboards, and display:none would remove it from the a11y
241
+ tree entirely. */
242
+ .hiddenInput {
243
+ position: absolute;
244
+ opacity: 0;
245
+ width: 1px;
246
+ height: 1px;
247
+ pointer-events: none;
248
+ }
249
+
250
+ .game { padding-top: 4px; }
251
+
252
+ .gameHud {
253
+ display: flex;
254
+ gap: 18px;
255
+ align-items: center;
256
+ border-top: 1px dashed #1F2A45;
257
+ border-bottom: 1px dashed #1F2A45;
258
+ padding: 6px 0;
259
+ margin-bottom: 8px;
260
+ font-size: 12px;
261
+ letter-spacing: 0.06em;
262
+ text-transform: uppercase;
263
+ }
264
+
265
+ .gameHud :global(.hud-label) { color: var(--term-fg-dim); font-size: 10px; }
266
+ .gameHud :global(.hud-val) { color: var(--term-fg); font-weight: 600; }
267
+
268
+ .gameScreen {
269
+ font-family: "IBM Plex Mono", ui-monospace, monospace;
270
+ font-size: 14px;
271
+ line-height: 1;
272
+ letter-spacing: 0.06em;
273
+ white-space: pre;
274
+ padding: 10px 12px;
275
+ margin: 0;
276
+ color: var(--term-fg);
277
+ background: rgba(6, 9, 18, 0.5);
278
+ border: 1px solid #15203A;
279
+ border-radius: 4px;
280
+ overflow: hidden;
281
+ }
282
+
283
+ .gameFooter {
284
+ margin-top: 8px;
285
+ padding-top: 6px;
286
+ border-top: 1px dashed #1F2A45;
287
+ font-size: 11px;
288
+ color: var(--term-fg-dim);
289
+ text-align: center;
290
+ letter-spacing: 0.04em;
291
+ }
292
+
293
+ .gameScreen :global(.g-ufo) { color: var(--term-cobalt); text-shadow: 0 0 6px rgba(104, 146, 217, 0.55); }
294
+ .gameScreen :global(.g-player) { color: var(--term-green); text-shadow: 0 0 6px rgba(95, 227, 154, 0.55); font-weight: 600; }
295
+ .gameScreen :global(.g-laser) { color: #F36C21; text-shadow: 0 0 8px rgba(243, 108, 33, 0.95); font-weight: 700; }
296
+ .gameScreen :global(.g-bomb) { color: #FF6B7A; text-shadow: 0 0 6px rgba(255, 107, 122, 0.6); }
297
+ .gameScreen :global(.g-boom) { color: #F7C76F; text-shadow: 0 0 10px rgba(247, 199, 111, 0.85); font-weight: 700; }
298
+
299
+ .gameHud :global(.g-laser) { color: #F36C21; }
300
+ .gameHud :global(.g-player) { color: var(--term-green); }
301
+
302
+ .gameFooter :global(.ok) { color: var(--term-green); }
303
+ .gameFooter :global(.warn) { color: #F7C76F; }
304
+ .gameFooter :global(.cmt) { color: var(--term-fg-dim); }