@conduction/docusaurus-preset 3.37.0 → 3.39.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 (42) hide show
  1. package/MISSING_COMPONENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/no-icu-messages.test.js +57 -0
  4. package/src/components/AiDisclosure/AiDisclosure.jsx +45 -21
  5. package/src/components/AiDisclosure/AiDisclosure.module.css +38 -3
  6. package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +25 -13
  7. package/src/components/AiDisclosure/__tests__/disclosure.test.js +60 -0
  8. package/src/components/AiDisclosure/disclosure.js +60 -4
  9. package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
  10. package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
  11. package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
  12. package/src/components/BlueprintRush/engine.js +172 -0
  13. package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
  14. package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
  15. package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
  16. package/src/components/DeadlineDefender/engine.js +188 -0
  17. package/src/components/DetailHero/DetailHero.jsx +11 -4
  18. package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
  19. package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
  20. package/src/components/FeaturedCard/FeaturedCard.module.css +10 -0
  21. package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
  22. package/src/components/GameModal/GameModal.jsx +255 -50
  23. package/src/components/GameModal/GameModal.module.css +103 -0
  24. package/src/components/GameModal/__tests__/scores.test.js +123 -0
  25. package/src/components/GameModal/__tests__/share.test.js +83 -0
  26. package/src/components/GameModal/scores.js +149 -0
  27. package/src/components/GameModal/share.js +97 -0
  28. package/src/components/RecordRun/RecordRun.jsx +245 -0
  29. package/src/components/RecordRun/RecordRun.module.css +208 -0
  30. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  31. package/src/components/RecordRun/engine.js +180 -0
  32. package/src/components/StampRush/StampRush.jsx +232 -0
  33. package/src/components/StampRush/StampRush.module.css +188 -0
  34. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  35. package/src/components/StampRush/engine.js +185 -0
  36. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  37. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  38. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  39. package/src/components/index.js +5 -0
  40. package/src/data/app-downloads.js +21 -0
  41. package/src/index.js +10 -0
  42. package/src/theme/Footer/index.jsx +10 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * FeaturedCard.visual.test.js — the `visual` prop puts a node in the
3
+ * card's right-hand column instead of the hex thumbnail.
4
+ *
5
+ * The regression half matters more than the feature half: every
6
+ * academy card and every featured post renders through this
7
+ * component, so a card that passes no `visual` has to render exactly
8
+ * what it rendered before, satellites and all.
9
+ *
10
+ * Same esbuild-bundle-then-renderToStaticMarkup technique as the
11
+ * other render tests; CSS modules are stubbed with an identity proxy.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const test = require('node:test');
17
+ const {before, after} = test;
18
+ const assert = require('node:assert/strict');
19
+ const path = require('node:path');
20
+ const fs = require('node:fs/promises');
21
+ const {build} = require('esbuild');
22
+ const React = require('react');
23
+ const {renderToStaticMarkup} = require('react-dom/server');
24
+
25
+ const COMPONENT = path.resolve(__dirname, '..', 'FeaturedCard.jsx');
26
+ const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
27
+ const SCRATCH = path.join(PRESET_ROOT, '.tmp-featured-card-test');
28
+
29
+ const cssModuleStub = {
30
+ name: 'css-module-stub',
31
+ setup(b) {
32
+ b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
33
+ b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
34
+ contents: 'export default new Proxy({}, {get: (_, p) => p});',
35
+ loader: 'js',
36
+ }));
37
+ },
38
+ };
39
+
40
+ const docusaurusStub = {
41
+ name: 'docusaurus-stub',
42
+ setup(b) {
43
+ b.onResolve({filter: /^@docusaurus\/Translate$/}, () => ({path: 'docusaurus-translate', namespace: 'docusaurus-stub'}));
44
+ b.onLoad({filter: /^docusaurus-translate$/, namespace: 'docusaurus-stub'}, () => ({
45
+ contents: `import React from 'react';
46
+ export function translate(o, v) {
47
+ return String(o.message).replace(/\\{(\\w+)\\}/g, (_, k) => (v && v[k] !== undefined ? v[k] : ''));
48
+ }
49
+ export default function Translate({children}) { return React.createElement(React.Fragment, null, children); }`,
50
+ loader: 'jsx',
51
+ }));
52
+ },
53
+ };
54
+
55
+ let FeaturedCard;
56
+
57
+ before(async () => {
58
+ await fs.mkdir(SCRATCH, {recursive: true});
59
+ const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
60
+ const outFile = path.join(tmpDir, 'bundle.cjs');
61
+ await build({
62
+ entryPoints: [COMPONENT],
63
+ outfile: outFile,
64
+ bundle: true,
65
+ format: 'cjs',
66
+ jsx: 'automatic',
67
+ jsxImportSource: 'react',
68
+ tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
69
+ platform: 'node',
70
+ external: ['react'],
71
+ plugins: [cssModuleStub, docusaurusStub],
72
+ logLevel: 'warning',
73
+ });
74
+ delete require.cache[require.resolve(outFile)];
75
+ FeaturedCard = require(outFile).default;
76
+ });
77
+
78
+ after(async () => {
79
+ await fs.rm(SCRATCH, {recursive: true, force: true});
80
+ });
81
+
82
+ const BASE = {title: 'La Frankendesk', lede: 'One finish, mixed parts.', href: '/academy/x'};
83
+
84
+ function render(props) {
85
+ return renderToStaticMarkup(React.createElement(FeaturedCard, {...BASE, ...props}));
86
+ }
87
+
88
+ const marker = React.createElement('div', {'data-visual': 'seam'}, 'scene');
89
+
90
+ test('without a visual the card renders the hex thumbnail and its satellites, as before', () => {
91
+ const html = render({thumbnail: {icon: React.createElement('svg', null), tone: 'cobalt'}});
92
+ assert.match(html, /thumb/, 'no hex thumbnail rendered');
93
+ assert.equal((html.match(/satellite/g) || []).length > 0, true, 'satellites missing');
94
+ assert.doesNotMatch(html, /visualNode/);
95
+ });
96
+
97
+ test('a visual takes the column and stands the satellites down', () => {
98
+ const html = render({visual: marker});
99
+ assert.match(html, /data-visual="seam"/, 'the passed node did not render');
100
+ assert.match(html, /visualNode/);
101
+ assert.doesNotMatch(html, /satellite/, 'satellites still frame a node that is not a hex');
102
+ assert.doesNotMatch(html, /thumb/, 'the hex thumbnail rendered alongside the visual');
103
+ });
104
+
105
+ test('the copy column is untouched by the visual', () => {
106
+ const html = render({visual: marker, eyebrow: 'blog', durationMinutes: 20});
107
+ assert.match(html, /La Frankendesk/);
108
+ assert.match(html, /One finish, mixed parts\./);
109
+ assert.match(html, /20 min read/);
110
+ });
@@ -42,60 +42,66 @@
42
42
 
43
43
  import React, {useEffect, useState, useCallback, useMemo} from 'react';
44
44
  import useIsBrowser from '@docusaurus/useIsBrowser';
45
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
45
46
  import Translate, {translate} from '@docusaurus/Translate';
47
+ import {
48
+ readScores, writeScores, recordResult, bestFor, foundCount as countFound,
49
+ totalScore, formatScore,
50
+ } from './scores';
51
+ import {buildShareText, scoreLines, mastodonShareUrl, linkedInShareUrl, normaliseInstance} from './share';
46
52
  import styles from './GameModal.module.css';
47
53
 
48
- const STORAGE_KEY = 'conduction:minigames';
49
-
50
- const DEFAULT_GAMES = [
51
- {id: 'hexrain', label: 'Twelve apps · hex rain'},
52
- {id: 'boats', label: 'Sink the boats · footer canal'},
53
- {id: 'invaders', label: 'Hex-vaders · cookie CLI'},
54
- {id: 'logo-memory', label: 'Logo memory · clients marquee'},
55
- {id: 'kade-cyclist', label: 'Kade cyclist · footer kade'},
56
- ];
57
-
58
- function readFound() {
59
- if (typeof window === 'undefined') return {};
60
- try {
61
- const raw = window.localStorage.getItem(STORAGE_KEY);
62
- return raw ? JSON.parse(raw) : {};
63
- } catch (e) { return {}; }
64
- }
54
+ /* The player's Mastodon instance, remembered so the second share does
55
+ not ask again. Per-viewer convenience only; nothing else reads it. */
56
+ const INSTANCE_KEY = 'conduction:mastodon-instance';
65
57
 
66
- function writeFound(found) {
67
- if (typeof window === 'undefined') return;
68
- try {
69
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(found));
70
- } catch (e) {/* fail open */}
58
+ /* The labels are read by every player, so they are translated like any
59
+ other user-facing string. Each one is "the game · where it hides";
60
+ the share text keeps only the part before the separator. Built in a
61
+ function rather than at module scope, because translate() must run
62
+ inside the render for the active locale to apply. */
63
+ function defaultGames() {
64
+ return [
65
+ {id: 'hexrain', label: translate({id: 'preset.gameModal.game.hexrain', message: 'Twelve apps · hex rain', description: 'Name of the hex-rain mini-game and where it hides'})},
66
+ {id: 'boats', label: translate({id: 'preset.gameModal.game.boats', message: 'Sink the boats · footer canal', description: 'Name of the boat-sinking mini-game and where it hides'})},
67
+ {id: 'invaders', label: translate({id: 'preset.gameModal.game.invaders', message: 'Hex-vaders · cookie CLI', description: 'Name of the invaders mini-game and where it hides'})},
68
+ {id: 'logo-memory', label: translate({id: 'preset.gameModal.game.logoMemory', message: 'Logo memory · clients marquee', description: 'Name of the logo-memory mini-game and where it hides'})},
69
+ {id: 'kade-cyclist', label: translate({id: 'preset.gameModal.game.kadeCyclist', message: 'Kade cyclist · footer kade', description: 'Name of the kade-cyclist mini-game and where it hides'})},
70
+ ];
71
71
  }
72
72
 
73
- export default function GameModal({games = DEFAULT_GAMES, className}) {
73
+ export default function GameModal({games: gamesProp, share: shareConfig, className}) {
74
74
  const isBrowser = useIsBrowser();
75
+ const {siteConfig, i18n} = useDocusaurusContext();
75
76
  const [open, setOpen] = useState(false);
76
77
  const [event, setEvent] = useState(null);
77
- const [found, setFound] = useState({});
78
+ const [scores, setScores] = useState(() => ({version: 2, games: {}}));
79
+ /* Share UI state: which network is mid-flow, the remembered Mastodon
80
+ instance, and the "copied" acknowledgement. */
81
+ const [instance, setInstance] = useState('');
82
+ const [askInstance, setAskInstance] = useState(false);
83
+ const [copied, setCopied] = useState(false);
78
84
 
79
- /* On mount: read found-games table from localStorage and subscribe
80
- to the `connext:gameend` event. Each event opens the modal with
81
- the supplied copy and (if won) marks the game as found. */
85
+ /* On mount: read the score table from localStorage and subscribe to
86
+ the `connext:gameend` event. Each event opens the modal with the
87
+ supplied copy and folds the result into the table. */
82
88
  useEffect(() => {
83
89
  if (!isBrowser) return;
84
- setFound(readFound());
90
+ setScores(readScores());
91
+ try {
92
+ setInstance(window.localStorage.getItem(INSTANCE_KEY) || '');
93
+ } catch (e) {/* blocked storage: the player types it again */}
85
94
 
86
95
  function onEnd(e) {
87
96
  const detail = e.detail || {};
88
97
  setEvent(detail);
89
98
  setOpen(true);
90
- /* Discovery vs. victory: any game-end counts the game as "found"
91
- because a few of the games (kade-cyclist, future endless
92
- runners) never reach a clean win state. The scoreboard pill
93
- still reflects the actual performance for that round. */
99
+ setCopied(false);
100
+ setAskInstance(false);
94
101
  if (detail.id) {
95
- setFound((prev) => {
96
- if (prev[detail.id]) return prev;
97
- const next = {...prev, [detail.id]: true};
98
- writeFound(next);
102
+ setScores((prev) => {
103
+ const next = recordResult(prev, detail);
104
+ writeScores(next);
99
105
  return next;
100
106
  });
101
107
  }
@@ -137,9 +143,88 @@ export default function GameModal({games = DEFAULT_GAMES, className}) {
137
143
  setOpen(false);
138
144
  }, [event]);
139
145
 
140
- const foundCount = useMemo(() => Object.values(found).filter(Boolean).length, [found]);
146
+ const locale = (i18n && i18n.currentLocale) || 'en';
147
+
148
+ /* Copy that reaches this component from a site's themeConfig can be
149
+ either a string or a per-locale map, because Docusaurus does not
150
+ translate themeConfig at all. A string is used as-is. */
151
+ const pickLocale = useCallback((value) => {
152
+ if (!value || typeof value === 'string') return value;
153
+ return value[locale] || value.en || Object.values(value)[0];
154
+ }, [locale]);
155
+
156
+ /* The roster: which games this site actually ships. The preset's own
157
+ five are the default, and a site that hides more of them passes
158
+ its own list (with per-locale labels, same reason as above). A
159
+ roster that named a game the site does not ship would leave every
160
+ player permanently short of "all found". */
161
+ const games = useMemo(
162
+ () => (gamesProp || defaultGames()).map((g) => ({...g, label: pickLocale(g.label)})),
163
+ [gamesProp, pickLocale],
164
+ );
165
+ const rosterIds = useMemo(() => games.map((g) => g.id), [games]);
166
+ const foundCount = useMemo(() => countFound(scores, rosterIds), [scores, rosterIds]);
141
167
  const total = games.length;
142
168
  const percent = total > 0 ? Math.round((foundCount / total) * 100) : 0;
169
+ const grandTotal = useMemo(() => totalScore(scores, rosterIds), [scores, rosterIds]);
170
+ const lines = useMemo(
171
+ () => scoreLines(games, (id) => bestFor(scores, id), locale),
172
+ [games, scores, locale],
173
+ );
174
+
175
+ /* The post the player publishes. Built here so the copy button, the
176
+ Mastodon link and the LinkedIn link cannot drift apart. */
177
+ const shareText = useMemo(() => buildShareText({
178
+ total: grandTotal,
179
+ lines,
180
+ foundCount,
181
+ totalGames: total,
182
+ hashtag: (shareConfig && shareConfig.hashtag) || '#IReadTheKit',
183
+ url: (shareConfig && shareConfig.url) || (siteConfig && siteConfig.url) || undefined,
184
+ locale,
185
+ }), [grandTotal, lines, foundCount, total, shareConfig, siteConfig, locale]);
186
+
187
+ const copyShareText = useCallback(async () => {
188
+ try {
189
+ await navigator.clipboard.writeText(shareText);
190
+ setCopied(true);
191
+ return true;
192
+ } catch (e) {
193
+ /* No clipboard permission (or no clipboard): fall back to a
194
+ hidden textarea, which works everywhere that still supports
195
+ execCommand, and give up quietly if that fails too. */
196
+ try {
197
+ const ta = document.createElement('textarea');
198
+ ta.value = shareText;
199
+ ta.setAttribute('readonly', '');
200
+ ta.style.position = 'fixed';
201
+ ta.style.opacity = '0';
202
+ document.body.appendChild(ta);
203
+ ta.select();
204
+ const ok = document.execCommand('copy');
205
+ document.body.removeChild(ta);
206
+ setCopied(ok);
207
+ return ok;
208
+ } catch (e2) { return false; }
209
+ }
210
+ }, [shareText]);
211
+
212
+ const shareOnMastodon = useCallback((raw) => {
213
+ const url = mastodonShareUrl(raw, shareText);
214
+ if (!url) { setAskInstance(true); return; }
215
+ const host = normaliseInstance(raw);
216
+ setInstance(host);
217
+ setAskInstance(false);
218
+ try { window.localStorage.setItem(INSTANCE_KEY, host); } catch (e) {/* fine */}
219
+ window.open(url, '_blank', 'noopener,noreferrer');
220
+ }, [shareText]);
221
+
222
+ const shareOnLinkedIn = useCallback(async () => {
223
+ /* LinkedIn stopped honouring prefilled text reliably, so the post
224
+ goes to the clipboard first and the composer opens for a paste. */
225
+ await copyShareText();
226
+ window.open(linkedInShareUrl(shareText), '_blank', 'noopener,noreferrer');
227
+ }, [copyShareText, shareText]);
143
228
 
144
229
  if (!isBrowser || !open || !event) return null;
145
230
 
@@ -188,27 +273,147 @@ export default function GameModal({games = DEFAULT_GAMES, className}) {
188
273
  </div>
189
274
 
190
275
  <ul className={styles.grid}>
191
- {games.map((g) => (
192
- <li key={g.id} className={found[g.id] ? styles.gridItemFound : styles.gridItem}>
193
- <span className={styles.gridHex} aria-hidden="true" />
194
- <span>{g.label}</span>
195
- </li>
196
- ))}
276
+ {games.map((g) => {
277
+ const best = bestFor(scores, g.id);
278
+ const isFound = Boolean(scores.games[g.id] && scores.games[g.id].found);
279
+ return (
280
+ <li key={g.id} className={isFound ? styles.gridItemFound : styles.gridItem}>
281
+ <span className={styles.gridHex} aria-hidden="true" />
282
+ <span className={styles.gridLabel}>{g.label}</span>
283
+ {best !== null && (
284
+ <span className={styles.gridScore}>{formatScore(best, locale)}</span>
285
+ )}
286
+ </li>
287
+ );
288
+ })}
197
289
  </ul>
198
290
 
291
+ {grandTotal > 0 && (
292
+ <p className={styles.total}>
293
+ <Translate
294
+ id="preset.gameModal.totalScore"
295
+ description="Total score line under the games list. {score} is the sum of the player's best score in every game."
296
+ values={{score: <strong>{formatScore(grandTotal, locale)}</strong>}}>
297
+ {'Total score {score}'}
298
+ </Translate>
299
+ </p>
300
+ )}
301
+
199
302
  <p className={styles.cta}>
303
+ {/* Two messages picked here rather than one ICU plural.
304
+ Docusaurus's translate() only substitutes {placeholder};
305
+ it does not expand plurals, so an ICU string renders to
306
+ the reader verbatim, braces and all, in every locale. */}
200
307
  {foundCount < total
201
- ? translate(
308
+ ? (total - foundCount === 1
309
+ ? translate({
310
+ id: 'preset.gameModal.cta.remaining.one',
311
+ message: 'One more game hidden somewhere. Keep clicking.',
312
+ description: 'CTA on the game-over modal when exactly one mini-game is still hidden.',
313
+ })
314
+ : translate(
315
+ {
316
+ id: 'preset.gameModal.cta.remaining.other',
317
+ message: '{remaining} more games hidden somewhere. Keep clicking.',
318
+ description: 'CTA on the game-over modal when several mini-games are still hidden. {remaining} is how many.',
319
+ },
320
+ {remaining: total - foundCount},
321
+ ))
322
+ : translate(
202
323
  {
203
- id: 'preset.gameModal.cta.remaining',
204
- message: '{remaining, plural, one {# more game hidden somewhere. Keep clicking.} other {# more games hidden somewhere. Keep clicking.}}',
205
- description: 'CTA text on the game-over modal when at least one game is still hidden. {remaining} is the count of games still hidden.',
324
+ id: 'preset.gameModal.cta.allFound',
325
+ message: 'All {total} found. You read the kit.',
326
+ description: 'CTA on the game-over modal when every mini-game has been discovered. {total} is how many games there are.',
206
327
  },
207
- {remaining: total - foundCount},
208
- )
209
- : translate({id: 'preset.gameModal.cta.allFound', message: 'All five found. You read the kit.', description: 'CTA text on the game-over modal when the player has discovered every mini-game.'})}
328
+ {total},
329
+ )}
210
330
  </p>
211
331
 
332
+ {/* Posting a score is the whole competition: there is no
333
+ leaderboard to submit to, so the share block is where a run
334
+ turns into something other people can see. */}
335
+ <div className={styles.share}>
336
+ <p className={styles.shareHead}>
337
+ <Translate id="preset.gameModal.share.head" description="Heading above the share buttons on the game-over modal">
338
+ Post your score
339
+ </Translate>
340
+ </p>
341
+ <p className={styles.shareHint}>
342
+ <Translate id="preset.gameModal.share.hint" description="Line under the share heading telling the player to attach a screenshot of the modal">
343
+ Add a screenshot of this card, so people can see the run behind the number.
344
+ </Translate>
345
+ </p>
346
+
347
+ <div className={styles.shareButtons}>
348
+ <button
349
+ type="button"
350
+ className={styles.shareBtn}
351
+ onClick={() => (instance ? shareOnMastodon(instance) : setAskInstance(true))}>
352
+ <Translate id="preset.gameModal.share.mastodon" description="Share-on-Mastodon button label">Mastodon</Translate>
353
+ </button>
354
+ <button type="button" className={styles.shareBtn} onClick={shareOnLinkedIn}>
355
+ <Translate id="preset.gameModal.share.linkedin" description="Share-on-LinkedIn button label">LinkedIn</Translate>
356
+ </button>
357
+ <button type="button" className={styles.shareBtn} onClick={copyShareText}>
358
+ <Translate id="preset.gameModal.share.copy" description="Copy-the-post-text button label">Copy the post</Translate>
359
+ </button>
360
+ <span className={styles.shareStatus} role="status" aria-live="polite">
361
+ {copied && (
362
+ <Translate id="preset.gameModal.share.copied" description="Confirmation shown after the post text is copied to the clipboard">
363
+ Copied. Paste it with your screenshot.
364
+ </Translate>
365
+ )}
366
+ </span>
367
+ </div>
368
+
369
+ {askInstance && (
370
+ /* Mastodon has no central share endpoint, so the post can
371
+ only be opened on the player's own instance. Asked once,
372
+ then remembered. */
373
+ <form
374
+ className={styles.instanceRow}
375
+ onSubmit={(e) => { e.preventDefault(); shareOnMastodon(e.target.elements.instance.value); }}>
376
+ <label className={styles.instanceLabel} htmlFor="gm-instance">
377
+ <Translate id="preset.gameModal.share.instanceLabel" description="Label for the input asking which Mastodon instance the player is on">
378
+ Your Mastodon instance
379
+ </Translate>
380
+ </label>
381
+ <input
382
+ id="gm-instance"
383
+ name="instance"
384
+ className={styles.instanceInput}
385
+ defaultValue={instance}
386
+ placeholder="mastodon.nl"
387
+ autoComplete="off"
388
+ />
389
+ <button type="submit" className={styles.shareBtn}>
390
+ <Translate id="preset.gameModal.share.instanceGo" description="Submit button next to the Mastodon instance input">Open</Translate>
391
+ </button>
392
+ </form>
393
+ )}
394
+
395
+ {shareConfig && pickLocale(shareConfig.prize) && (
396
+ /* The prize sentence stays text and only the rules link is
397
+ a link: a whole underlined paragraph reads as one long
398
+ link and hides where it goes. */
399
+ <p className={styles.prize}>
400
+ {pickLocale(shareConfig.prize)}
401
+ {shareConfig.prizeHref && (
402
+ <>
403
+ {' '}
404
+ <a href={shareConfig.prizeHref}>
405
+ {pickLocale(shareConfig.prizeLinkLabel) || translate({
406
+ id: 'preset.gameModal.share.rules',
407
+ message: 'Read the rules',
408
+ description: 'Link to the giveaway rules, shown after the prize line in the share block',
409
+ })}
410
+ </a>
411
+ </>
412
+ )}
413
+ </p>
414
+ )}
415
+ </div>
416
+
212
417
  <div className={styles.actions}>
213
418
  <button type="button" className={styles.btnSecondary} onClick={close}>
214
419
  <Translate id="preset.gameModal.action.close" description="Close button label on the game-over modal">Close</Translate>
@@ -142,6 +142,109 @@
142
142
  }
143
143
  .gridItemFound .gridHex { background: var(--c-mint-500); }
144
144
 
145
+ /* Per-game best, right-aligned against the game's label. */
146
+ .gridLabel { flex: 1; }
147
+ .gridScore {
148
+ font-family: var(--conduction-typography-font-family-code);
149
+ font-size: 12px;
150
+ color: var(--c-cobalt-700);
151
+ font-variant-numeric: tabular-nums;
152
+ }
153
+
154
+ .total {
155
+ margin: 12px 0 0;
156
+ font-size: 15px;
157
+ color: var(--c-cobalt-700);
158
+ }
159
+ .total strong {
160
+ color: var(--c-cobalt-900);
161
+ font-family: var(--conduction-typography-font-family-code);
162
+ font-variant-numeric: tabular-nums;
163
+ }
164
+
165
+ /* ======================================================================
166
+ Share block
167
+ ====================================================================== */
168
+
169
+ .share {
170
+ margin: 20px 0 0;
171
+ padding: 16px;
172
+ background: var(--c-cobalt-50);
173
+ border-radius: var(--radius-md);
174
+ }
175
+
176
+ .shareHead {
177
+ margin: 0 0 4px;
178
+ font-weight: 600;
179
+ font-size: 14px;
180
+ color: var(--c-cobalt-900);
181
+ }
182
+
183
+ .shareHint {
184
+ margin: 0 0 12px;
185
+ font-size: 13px;
186
+ color: var(--c-cobalt-400);
187
+ }
188
+
189
+ .shareButtons {
190
+ display: flex;
191
+ flex-wrap: wrap;
192
+ align-items: center;
193
+ gap: 8px;
194
+ }
195
+
196
+ .shareBtn {
197
+ background: white;
198
+ color: var(--c-cobalt-700);
199
+ border: 1px solid var(--c-cobalt-200);
200
+ padding: 8px 14px;
201
+ border-radius: var(--radius-pill);
202
+ font-weight: 500;
203
+ font-size: 13px;
204
+ cursor: pointer;
205
+ font-family: inherit;
206
+ transition: border-color 120ms ease, color 120ms ease;
207
+ }
208
+ .shareBtn:hover { border-color: var(--c-blue-cobalt); color: var(--c-blue-cobalt); }
209
+
210
+ .shareStatus {
211
+ font-size: 12px;
212
+ color: var(--c-mint-500);
213
+ min-height: 1em;
214
+ }
215
+
216
+ .instanceRow {
217
+ display: flex;
218
+ flex-wrap: wrap;
219
+ align-items: center;
220
+ gap: 8px;
221
+ margin-top: 12px;
222
+ }
223
+ .instanceLabel { font-size: 12px; color: var(--c-cobalt-700); }
224
+ .instanceInput {
225
+ flex: 1;
226
+ min-width: 160px;
227
+ padding: 7px 10px;
228
+ border: 1px solid var(--c-cobalt-200);
229
+ border-radius: var(--radius-md);
230
+ font-family: var(--conduction-typography-font-family-code);
231
+ font-size: 13px;
232
+ color: var(--c-cobalt-900);
233
+ background: white;
234
+ }
235
+ .instanceInput:focus {
236
+ outline: 2px solid var(--c-blue-cobalt);
237
+ outline-offset: 1px;
238
+ border-color: var(--c-blue-cobalt);
239
+ }
240
+
241
+ .prize {
242
+ margin: 12px 0 0;
243
+ font-size: 13px;
244
+ color: var(--c-cobalt-700);
245
+ }
246
+ .prize a { color: var(--c-blue-cobalt); }
247
+
145
248
  .cta {
146
249
  font-size: 13px;
147
250
  color: var(--c-cobalt-400);