@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.
- package/package.json +4 -4
- package/src/components/AppMock/AppMock.jsx +16 -0
- package/src/components/AppMock/variants/AppVersionsMock.jsx +83 -0
- package/src/components/AppMock/variants/DoriathMock.jsx +79 -0
- package/src/components/AppMock/variants/HermiqMock.jsx +71 -0
- package/src/components/AppMock/variants/HrmqMock.jsx +77 -0
- package/src/components/AppMock/variants/PlanixMock.jsx +80 -0
- package/src/components/AppMock/variants/PortaliqMock.jsx +63 -0
- package/src/components/AppMock/variants/ScholiqMock.jsx +74 -0
- package/src/components/AppMock/variants/ShillinqMock.jsx +90 -0
- package/src/components/CookieCli/CookieCli.jsx +197 -24
- package/src/components/CookieCli/CookieCli.module.css +139 -1
- package/src/components/CookieCli/shell.js +161 -0
- package/src/components/CookieCli/spaceInvaders.js +359 -0
- package/src/components/CookieCli/spaceInvaders.test.js +112 -0
- package/src/data/app-glyphs.json +4 -0
|
@@ -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
|
-
/*
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
+
}
|