@conduction/docusaurus-preset 3.41.1 → 3.42.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.41.1",
3
+ "version": "3.42.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js",
6
6
  "test": "node --test"
@@ -323,9 +323,15 @@ export default function DetailHero({
323
323
  {title && (
324
324
  <h1 className={styles.title}>
325
325
  {resolvedIcon && (
326
+ /* The glyph doubles as a hiding place. <HiddenGame>
327
+ attaches its click and hold unlocks to whatever
328
+ carries this marker, so a page can hide a game
329
+ behind its own logo without the hero knowing which
330
+ game, or that there is one. */
326
331
  <span
327
332
  className={styles.titleIcon}
328
333
  style={{background: resolvedIconColor}}
334
+ data-hidden-target="app-glyph"
329
335
  aria-hidden="true"
330
336
  >
331
337
  {resolvedIcon}
@@ -0,0 +1,190 @@
1
+ /**
2
+ * <HiddenGame />
3
+ *
4
+ * Wraps a mini-game and keeps it off the page until somebody finds it.
5
+ * Every game on the site is hidden behind a different way in, because
6
+ * the hunt is the game around the games: one wants three clicks on the
7
+ * app's logo, another the Konami code, another a word typed on the
8
+ * page, another a paragraph selected as if you were about to redact
9
+ * it.
10
+ *
11
+ * The matching lives in ./matchers.js, with no DOM and no clock, so
12
+ * "does this open it" and "does this open by accident" are tested
13
+ * rather than tried.
14
+ *
15
+ * Usage:
16
+ *
17
+ * <HiddenGame id="stamp-rush" unlock={{kind: 'clicks', target: 'app-glyph'}}>
18
+ * <StampRush />
19
+ * </HiddenGame>
20
+ *
21
+ * Unlock kinds:
22
+ * clicks {target, count = 3, windowMs} clicks on an element carrying
23
+ * data-hidden-target="<target>"
24
+ * hold {target, holdMs} press and hold that element
25
+ * type {word} type a word anywhere on the page
26
+ * konami {} the Konami code
27
+ * select {minLength, settleMs} select a run of text and pause
28
+ * link {} renders its own quiet opener
29
+ *
30
+ * Every kind also answers to `#play-<id>` in the URL, which is how a
31
+ * page links straight to its own game (the arcade page does) and how
32
+ * the e2e suite gets in without re-testing the matchers through a
33
+ * browser.
34
+ *
35
+ * Once found, a game stays found for that browser: reopening the page
36
+ * shows it straight away, because hiding it again would punish the
37
+ * person who solved it.
38
+ */
39
+
40
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
41
+ import {translate} from '@docusaurus/Translate';
42
+ import useIsBrowser from '@docusaurus/useIsBrowser';
43
+ import {
44
+ createSequenceMatcher, createWordMatcher, createClickCounter,
45
+ createHoldTimer, createSelectionWatcher, KONAMI,
46
+ } from './matchers';
47
+ import styles from './HiddenGame.module.css';
48
+
49
+ const FOUND_KEY = 'conduction:minigames-found';
50
+
51
+ function readFound(id) {
52
+ if (typeof window === 'undefined') return false;
53
+ try {
54
+ const raw = window.localStorage.getItem(FOUND_KEY);
55
+ return Boolean(raw && JSON.parse(raw)[id]);
56
+ } catch (e) { return false; }
57
+ }
58
+
59
+ function writeFound(id) {
60
+ if (typeof window === 'undefined') return;
61
+ try {
62
+ const raw = window.localStorage.getItem(FOUND_KEY);
63
+ const found = raw ? JSON.parse(raw) : {};
64
+ found[id] = true;
65
+ window.localStorage.setItem(FOUND_KEY, JSON.stringify(found));
66
+ } catch (e) {/* fail open: the game still opened, it just won't be remembered */}
67
+ }
68
+
69
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
70
+
71
+ export default function HiddenGame({id, unlock = {}, children, className}) {
72
+ const isBrowser = useIsBrowser();
73
+ const [open, setOpen] = useState(false);
74
+ const holder = useRef(null);
75
+ const openedRef = useRef(false);
76
+
77
+ const reveal = useCallback(() => {
78
+ if (openedRef.current) return;
79
+ openedRef.current = true;
80
+ setOpen(true);
81
+ writeFound(id);
82
+ /* Bring it into view: a game that opens below the fold looks like
83
+ nothing happened, and the player goes back to poking the logo. */
84
+ window.requestAnimationFrame(() => {
85
+ if (holder.current) holder.current.scrollIntoView({behavior: 'smooth', block: 'center'});
86
+ });
87
+ }, [id]);
88
+
89
+ /* Already found here before, or linked to directly. */
90
+ useEffect(() => {
91
+ if (!isBrowser) return;
92
+ if (readFound(id) || window.location.hash === `#play-${id}`) {
93
+ openedRef.current = true;
94
+ setOpen(true);
95
+ }
96
+ }, [isBrowser, id]);
97
+
98
+ /* Typed words and the Konami code both listen on the document. */
99
+ useEffect(() => {
100
+ if (!isBrowser || open) return undefined;
101
+ if (unlock.kind !== 'type' && unlock.kind !== 'konami') return undefined;
102
+
103
+ const matcher = unlock.kind === 'konami'
104
+ ? createSequenceMatcher(KONAMI)
105
+ : createWordMatcher(unlock.word || '');
106
+
107
+ const onKey = (e) => {
108
+ /* Never steal from a field somebody is typing in. */
109
+ const el = e.target;
110
+ if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return;
111
+ if (matcher.push(e.key)) reveal();
112
+ };
113
+ window.addEventListener('keydown', onKey);
114
+ return () => window.removeEventListener('keydown', onKey);
115
+ }, [isBrowser, open, unlock.kind, unlock.word, reveal]);
116
+
117
+ /* Clicks and holds attach to whatever carries the target marker. */
118
+ useEffect(() => {
119
+ if (!isBrowser || open) return undefined;
120
+ if (unlock.kind !== 'clicks' && unlock.kind !== 'hold') return undefined;
121
+
122
+ const target = document.querySelector(`[data-hidden-target="${unlock.target}"]`);
123
+ if (!target) return undefined;
124
+
125
+ if (unlock.kind === 'clicks') {
126
+ const counter = createClickCounter({count: unlock.count || 3, windowMs: unlock.windowMs || 1500});
127
+ const onClick = () => { if (counter.push(now())) reveal(); };
128
+ target.addEventListener('click', onClick);
129
+ return () => target.removeEventListener('click', onClick);
130
+ }
131
+
132
+ const timer = createHoldTimer({holdMs: unlock.holdMs || 1200});
133
+ let poll = null;
134
+ const start = () => {
135
+ timer.start(now());
136
+ poll = setInterval(() => { if (timer.check(now())) { clearInterval(poll); reveal(); } }, 100);
137
+ };
138
+ const stop = () => { timer.cancel(); if (poll) clearInterval(poll); };
139
+ target.addEventListener('pointerdown', start);
140
+ target.addEventListener('pointerup', stop);
141
+ target.addEventListener('pointerleave', stop);
142
+ return () => {
143
+ stop();
144
+ target.removeEventListener('pointerdown', start);
145
+ target.removeEventListener('pointerup', stop);
146
+ target.removeEventListener('pointerleave', stop);
147
+ };
148
+ }, [isBrowser, open, unlock.kind, unlock.target, unlock.count, unlock.windowMs, unlock.holdMs, reveal]);
149
+
150
+ /* Selecting text, for the game about selecting text. */
151
+ useEffect(() => {
152
+ if (!isBrowser || open || unlock.kind !== 'select') return undefined;
153
+
154
+ const watcher = createSelectionWatcher({
155
+ minLength: unlock.minLength || 12,
156
+ settleMs: unlock.settleMs || 900,
157
+ });
158
+ const tick = setInterval(() => {
159
+ const text = window.getSelection ? String(window.getSelection()) : '';
160
+ if (watcher.push(text, now())) reveal();
161
+ }, 200);
162
+ return () => clearInterval(tick);
163
+ }, [isBrowser, open, unlock.kind, unlock.minLength, unlock.settleMs, reveal]);
164
+
165
+ if (!isBrowser) return null;
166
+
167
+ if (!open) {
168
+ /* The `link` kind is the one hiding place that shows itself, for a
169
+ page with nothing else to poke at. Everything else renders
170
+ nothing at all: an empty wrapper is the point. */
171
+ if (unlock.kind !== 'link') return null;
172
+ return (
173
+ <p className={[styles.opener, className].filter(Boolean).join(' ')}>
174
+ <button type="button" className={styles.openerButton} onClick={reveal}>
175
+ {unlock.label || translate({
176
+ id: 'preset.hiddenGame.opener',
177
+ message: 'Take a break',
178
+ description: 'Quiet link that opens a hidden mini-game on a page',
179
+ })}
180
+ </button>
181
+ </p>
182
+ );
183
+ }
184
+
185
+ return (
186
+ <div className={[styles.found, className].filter(Boolean).join(' ')} ref={holder}>
187
+ {children}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * <HiddenGame /> styles.
3
+ *
4
+ * Almost nothing, on purpose: a hidden game renders nothing until it
5
+ * is found, and once found it is the game's own styles that matter.
6
+ * Only two things live here, the quiet opener for the `link` kind and
7
+ * the arrival of the game itself.
8
+ */
9
+
10
+ .opener {
11
+ margin: 0;
12
+ text-align: center;
13
+ }
14
+
15
+ /* Deliberately understated. It is meant to be found by someone
16
+ reading the page, not to advertise itself from across the screen. */
17
+ .openerButton {
18
+ border: 0;
19
+ background: none;
20
+ padding: 4px 6px;
21
+ font-family: var(--conduction-typography-font-family-code);
22
+ font-size: 11px;
23
+ letter-spacing: 0.08em;
24
+ text-transform: uppercase;
25
+ color: var(--c-cobalt-300);
26
+ cursor: pointer;
27
+ transition: color 140ms ease;
28
+ }
29
+ .openerButton:hover { color: var(--c-blue-cobalt); }
30
+ .openerButton:focus-visible {
31
+ outline: 2px solid var(--c-blue-cobalt);
32
+ outline-offset: 2px;
33
+ color: var(--c-blue-cobalt);
34
+ }
35
+
36
+ @media (prefers-reduced-motion: no-preference) {
37
+ .found { animation: hgArrive 260ms ease-out both; }
38
+ @keyframes hgArrive {
39
+ from { opacity: 0; transform: translateY(8px); }
40
+ to { opacity: 1; transform: none; }
41
+ }
42
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * matchers.test.js — how a hidden game gets found.
3
+ *
4
+ * These are the only thing standing between a game nobody can reach
5
+ * and a game that opens by accident, so both halves are pinned: the
6
+ * intended input opens it, and the near misses do not.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const test = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+
14
+ const {
15
+ createSequenceMatcher, createWordMatcher, createClickCounter,
16
+ createHoldTimer, createSelectionWatcher, KONAMI,
17
+ } = require('../matchers.js');
18
+
19
+ /** Feed a list of keys, returning how many times it fired. */
20
+ function feed(matcher, keys) {
21
+ return keys.reduce((hits, k) => hits + (matcher.push(k) ? 1 : 0), 0);
22
+ }
23
+
24
+ test('the Konami code opens it, once', () => {
25
+ const m = createSequenceMatcher(KONAMI);
26
+ assert.equal(feed(m, KONAMI), 1);
27
+ assert.equal(m.progress, 0, 'the sequence did not reset after firing');
28
+ });
29
+
30
+ test('a wrong key resets the sequence, but a repeat of the first key restarts it', () => {
31
+ const m = createSequenceMatcher(['ArrowUp', 'ArrowDown', 'b']);
32
+ assert.equal(feed(m, ['ArrowUp', 'x', 'ArrowDown', 'b']), 0, 'a wrong key did not break the run');
33
+
34
+ /* "up up down b" has to work: the second up is a fresh start, not a
35
+ failure, or nobody ever lands a sequence that repeats a key. */
36
+ m.reset();
37
+ assert.equal(feed(m, ['ArrowUp', 'ArrowUp', 'ArrowDown', 'b']), 1);
38
+ });
39
+
40
+ test('the sequence ignores case, the way the Konami b and a are typed', () => {
41
+ const m = createSequenceMatcher(KONAMI);
42
+ const shouted = KONAMI.map((k) => (k.length === 1 ? k.toUpperCase() : k));
43
+ assert.equal(feed(m, shouted), 1);
44
+ });
45
+
46
+ test('a typed word opens it, even when the typing was messy before it', () => {
47
+ const m = createWordMatcher('build');
48
+ assert.equal(feed(m, 'buil'.split('')), 0);
49
+ assert.equal(feed(m, 'd'.split('')), 1);
50
+
51
+ m.reset();
52
+ assert.equal(feed(m, 'bbuild'.split('')), 1, 'a stutter before the word broke it');
53
+ m.reset();
54
+ assert.equal(feed(m, 'xxbuild'.split('')), 1, 'typing near it first broke it');
55
+ });
56
+
57
+ test('a typed word ignores keys that are not characters', () => {
58
+ const m = createWordMatcher('d20');
59
+ assert.equal(feed(m, ['d', 'Shift', '2', 'ArrowLeft', '0']), 1);
60
+ });
61
+
62
+ test('a word that is only half typed stays shut', () => {
63
+ const m = createWordMatcher('hunter2');
64
+ assert.equal(feed(m, 'hunter'.split('')), 0);
65
+ assert.equal(m.buffer, 'hunter');
66
+ });
67
+
68
+ test('three clicks in a row open it; three clicks spread out do not', () => {
69
+ const quick = createClickCounter({count: 3, windowMs: 1500});
70
+ assert.equal(quick.push(0), false);
71
+ assert.equal(quick.push(300), false);
72
+ assert.equal(quick.push(600), true);
73
+
74
+ const slow = createClickCounter({count: 3, windowMs: 1500});
75
+ assert.equal(slow.push(0), false);
76
+ assert.equal(slow.push(5000), false);
77
+ assert.equal(slow.push(10000), false, 'clicks a whole visit apart were counted together');
78
+ });
79
+
80
+ test('the click window slides rather than resetting, so a fourth click still lands', () => {
81
+ const c = createClickCounter({count: 3, windowMs: 1000});
82
+ assert.equal(c.push(0), false);
83
+ assert.equal(c.push(1800), false, 'the first click should have aged out');
84
+ assert.equal(c.push(2000), false);
85
+ assert.equal(c.push(2400), true);
86
+ });
87
+
88
+ test('a hold has to last, and a cancelled hold counts for nothing', () => {
89
+ const h = createHoldTimer({holdMs: 1200});
90
+ h.start(0);
91
+ assert.equal(h.check(900), false);
92
+ assert.equal(h.check(1300), true);
93
+
94
+ h.start(2000);
95
+ h.cancel();
96
+ assert.equal(h.check(9000), false, 'a hold that left the element still opened it');
97
+ assert.equal(h.holding, false);
98
+ });
99
+
100
+ test('a selection has to settle before it counts', () => {
101
+ const s = createSelectionWatcher({minLength: 10, settleMs: 900});
102
+ assert.equal(s.push('a permit application', 0), false, 'fired on the first frame of a drag');
103
+ assert.equal(s.push('a permit application', 500), false);
104
+ assert.equal(s.push('a permit application', 1000), true);
105
+ });
106
+
107
+ test('a growing selection keeps restarting the clock, and a short one never starts it', () => {
108
+ const s = createSelectionWatcher({minLength: 10, settleMs: 900});
109
+ s.push('a permit app', 0);
110
+ s.push('a permit application', 800);
111
+ assert.equal(s.push('a permit application', 1200), false, 'the clock did not restart when the drag grew');
112
+ assert.equal(s.push('a permit application', 1800), true);
113
+
114
+ s.reset();
115
+ assert.equal(s.push('short', 0), false);
116
+ assert.equal(s.push('short', 5000), false, 'a tiny selection opened it');
117
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Unlock matchers — how a hidden game gets found.
3
+ *
4
+ * Every mini-game on a Conduction page is hidden, and every one is
5
+ * hidden differently: finding them is the game around the games. These
6
+ * are the little state machines behind that, kept pure so each one can
7
+ * be tested without a page, a keyboard or a clock of its own.
8
+ *
9
+ * Each matcher takes events and answers the same question: has this
10
+ * been done yet? They never touch the DOM; the component feeds them.
11
+ */
12
+
13
+ /**
14
+ * A fixed key sequence, the Konami shape.
15
+ *
16
+ * Partial progress survives a wrong key only when that key could start
17
+ * the sequence again, which is what makes "up up down…" forgiving
18
+ * enough to actually land: the second `up` is not a reset, it is a
19
+ * fresh first step.
20
+ */
21
+ export function createSequenceMatcher(sequence) {
22
+ const wanted = sequence.map((k) => String(k).toLowerCase());
23
+ let index = 0;
24
+
25
+ return {
26
+ get progress() { return index; },
27
+ get length() { return wanted.length; },
28
+ reset() { index = 0; },
29
+ push(key) {
30
+ const k = String(key || '').toLowerCase();
31
+ if (k === wanted[index]) {
32
+ index += 1;
33
+ } else if (k === wanted[0]) {
34
+ index = 1;
35
+ } else {
36
+ index = 0;
37
+ }
38
+ if (index === wanted.length) {
39
+ index = 0;
40
+ return true;
41
+ }
42
+ return false;
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * A word typed anywhere on the page.
49
+ *
50
+ * Keeps a rolling buffer the length of the word rather than resetting
51
+ * on every mistake, so typing "hunthunter2" still opens it. Anything
52
+ * that is not a single printable character is ignored, so shift,
53
+ * arrows and tabbing about are harmless.
54
+ */
55
+ export function createWordMatcher(word) {
56
+ const wanted = String(word).toLowerCase();
57
+ let buffer = '';
58
+
59
+ return {
60
+ get buffer() { return buffer; },
61
+ reset() { buffer = ''; },
62
+ push(key) {
63
+ const k = String(key || '');
64
+ if (k.length !== 1) return false;
65
+ buffer = (buffer + k.toLowerCase()).slice(-wanted.length);
66
+ if (buffer === wanted) {
67
+ buffer = '';
68
+ return true;
69
+ }
70
+ return false;
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * N clicks on one thing, inside a window.
77
+ *
78
+ * The window is what keeps an accidental unlock from being assembled
79
+ * over a whole visit: three clicks a minute apart are three people
80
+ * reading, not somebody poking at the logo.
81
+ */
82
+ export function createClickCounter({count = 3, windowMs = 1500} = {}) {
83
+ let times = [];
84
+
85
+ return {
86
+ get progress() { return times.length; },
87
+ reset() { times = []; },
88
+ push(now) {
89
+ const t = Number(now) || 0;
90
+ times = times.filter((prev) => t - prev <= windowMs);
91
+ times.push(t);
92
+ if (times.length >= count) {
93
+ times = [];
94
+ return true;
95
+ }
96
+ return false;
97
+ },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Something held down, or hovered, for long enough.
103
+ *
104
+ * Start and end are separate calls because a press that leaves the
105
+ * element never ends: the component cancels it instead, and a
106
+ * cancelled hold must not count.
107
+ */
108
+ export function createHoldTimer({holdMs = 1200} = {}) {
109
+ let startedAt = null;
110
+
111
+ return {
112
+ get holding() { return startedAt !== null; },
113
+ start(now) { startedAt = Number(now) || 0; },
114
+ cancel() { startedAt = null; },
115
+ check(now) {
116
+ if (startedAt === null) return false;
117
+ if ((Number(now) || 0) - startedAt >= holdMs) {
118
+ startedAt = null;
119
+ return true;
120
+ }
121
+ return false;
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A run of text selected on the page, held for a moment.
128
+ *
129
+ * The moment matters: a selection appears halfway through every drag,
130
+ * so unlocking on the first one would fire while somebody is still
131
+ * choosing what to copy.
132
+ */
133
+ export function createSelectionWatcher({minLength = 12, settleMs = 900} = {}) {
134
+ let since = null;
135
+ let last = '';
136
+
137
+ return {
138
+ reset() { since = null; last = ''; },
139
+ /** Feed the current selection text and the time; returns true once. */
140
+ push(text, now) {
141
+ const value = String(text || '').trim();
142
+ const t = Number(now) || 0;
143
+ if (value.length < minLength) {
144
+ since = null;
145
+ last = '';
146
+ return false;
147
+ }
148
+ if (value !== last) {
149
+ last = value;
150
+ since = t;
151
+ return false;
152
+ }
153
+ if (since !== null && t - since >= settleMs) {
154
+ since = null;
155
+ last = '';
156
+ return true;
157
+ }
158
+ return false;
159
+ },
160
+ };
161
+ }
162
+
163
+ /** The Konami code, spelled the way KeyboardEvent.key spells it. */
164
+ export const KONAMI = [
165
+ 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
166
+ 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
167
+ 'b', 'a',
168
+ ];
@@ -64,6 +64,7 @@ export {default as StampRush} from './StampRush/StampRush.jsx';
64
64
  export {default as DeadlineDefender} from './DeadlineDefender/DeadlineDefender.jsx';
65
65
  export {default as BlueprintRush} from './BlueprintRush/BlueprintRush.jsx';
66
66
  export {default as RecordRun} from './RecordRun/RecordRun.jsx';
67
+ export {default as HiddenGame} from './HiddenGame/HiddenGame.jsx';
67
68
  export {default as LockPick} from './LockPick/LockPick.jsx';
68
69
  export {default as PaintByTokens} from './PaintByTokens/PaintByTokens.jsx';
69
70
  export {default as Redaction} from './Redaction/Redaction.jsx';