@conduction/docusaurus-preset 3.28.0 → 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.
@@ -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); }
@@ -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
+ }