@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,163 @@
1
+ /**
2
+ * engine.test.js — the deadline-defender rules.
3
+ *
4
+ * Two things are worth protecting. A case that runs out of time costs
5
+ * the same as one sent to the wrong step, because in a real queue both
6
+ * end the same way. And every situation the game deals has exactly one
7
+ * correct lane, or the player is being asked to guess.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+
15
+ const {
16
+ createGame, step, route, clockMs, remaining, summarise,
17
+ SITUATIONS, LANES, INTAKE, REVIEW, DECISION, DEFAULTS,
18
+ } = require('../engine.js');
19
+
20
+ /** Play correctly for a while, returning the state and what was dealt. */
21
+ function playPerfect(seed, cases, startAt = 0) {
22
+ let s = createGame({seed, now: startAt});
23
+ let t = startAt;
24
+ const dealt = [];
25
+ for (let i = 0; i < cases; i++) {
26
+ s = step(s, t);
27
+ if (!s.current) { t += 60; i--; continue; }
28
+ dealt.push(s.current);
29
+ t += 200;
30
+ s = route(s, s.current.needs, t);
31
+ t += DEFAULTS.gapMs + 20;
32
+ }
33
+ return {state: s, dealt};
34
+ }
35
+
36
+ test('every situation the game can deal has exactly one correct lane', () => {
37
+ for (const sit of SITUATIONS) {
38
+ assert.ok(LANES.includes(sit.needs), `${sit.key} routes nowhere`);
39
+ }
40
+ /* All three lanes have to be reachable, or one button is decoration. */
41
+ for (const lane of LANES) {
42
+ assert.ok(SITUATIONS.some((s) => s.needs === lane), `nothing ever goes to ${lane}`);
43
+ }
44
+ });
45
+
46
+ test('a case is dealt, and it carries a number and a deadline', () => {
47
+ let s = step(createGame({seed: 2, now: 0}), 0);
48
+ assert.ok(s.current, 'no case arrived');
49
+ assert.match(s.current.id, /^\d{4}-\d{3}$/);
50
+ assert.equal(s.current.expiresAt - s.current.bornAt, DEFAULTS.clockStartMs);
51
+ });
52
+
53
+ test('routing correctly scores, and a streak pays more each time', () => {
54
+ const {state} = playPerfect(4, 2);
55
+ assert.equal(state.handled, 2);
56
+ assert.equal(state.score, DEFAULTS.pointsPerCase * 2 + DEFAULTS.comboBonus);
57
+ assert.equal(state.lives, DEFAULTS.lives);
58
+ assert.equal(state.bestCombo, 2);
59
+ });
60
+
61
+ test('the wrong step costs a life and breaks the streak', () => {
62
+ let s = step(createGame({seed: 6, now: 0}), 0);
63
+ const wrong = LANES.find((l) => l !== s.current.needs);
64
+ const before = s.score;
65
+ s = route(s, wrong, 100);
66
+ assert.equal(s.lives, DEFAULTS.lives - 1);
67
+ assert.equal(s.score, before, 'a misroute must not also pay');
68
+ assert.equal(s.misrouted, 1);
69
+ assert.equal(s.combo, 0);
70
+ assert.equal(s.current, null, 'the misrouted case stayed on the desk');
71
+ });
72
+
73
+ test('letting the deadline run out costs the same as misrouting it', () => {
74
+ let s = step(createGame({seed: 6, now: 0}), 0);
75
+ s = step(s, DEFAULTS.clockStartMs + 1);
76
+ assert.equal(s.lives, DEFAULTS.lives - 1);
77
+ assert.equal(s.missed, 1);
78
+ assert.equal(s.current, null);
79
+ });
80
+
81
+ test('the next case waits for the gap, then arrives', () => {
82
+ let s = step(createGame({seed: 8, now: 0}), 0);
83
+ s = route(s, s.current.needs, 100);
84
+ assert.equal(s.current, null);
85
+ s = step(s, 100 + DEFAULTS.gapMs - 50);
86
+ assert.equal(s.current, null, 'the queue skipped its own gap');
87
+ s = step(s, 100 + DEFAULTS.gapMs + 10);
88
+ assert.ok(s.current, 'the queue stalled');
89
+ });
90
+
91
+ test('three mistakes end the run, and nothing scores after it', () => {
92
+ let s = createGame({seed: 9, now: 0});
93
+ let t = 0;
94
+ for (let i = 0; i < 3; i++) {
95
+ s = step(s, t);
96
+ const wrong = LANES.find((l) => l !== s.current.needs);
97
+ t += 100;
98
+ s = route(s, wrong, t);
99
+ t += DEFAULTS.gapMs + 20;
100
+ }
101
+ assert.equal(s.over, true);
102
+ assert.equal(s.lives, 0);
103
+
104
+ const frozen = {...s};
105
+ assert.equal(step(frozen, t + 5000).score, s.score);
106
+ assert.equal(route(frozen, INTAKE, t + 10).score, s.score);
107
+ });
108
+
109
+ test('an unknown lane is ignored rather than counted as a mistake', () => {
110
+ let s = step(createGame({seed: 3, now: 0}), 0);
111
+ const after = route(s, 'archive', 50);
112
+ assert.equal(after.lives, s.lives);
113
+ assert.equal(after.current, s.current);
114
+ });
115
+
116
+ test('routing an empty desk does nothing', () => {
117
+ const s = createGame({seed: 3, now: 0});
118
+ assert.equal(route(s, INTAKE, 10).lives, s.lives);
119
+ });
120
+
121
+ test('the deadline tightens with the score, down to a readable floor', () => {
122
+ const fresh = createGame({seed: 1});
123
+ assert.equal(clockMs(fresh), DEFAULTS.clockStartMs);
124
+ assert.ok(clockMs({...fresh, score: 200}) < DEFAULTS.clockStartMs);
125
+ assert.equal(clockMs({...fresh, score: 5000}), DEFAULTS.clockFloorMs);
126
+ });
127
+
128
+ test('the countdown runs from full to empty, and never past either end', () => {
129
+ let s = step(createGame({seed: 5, now: 0}), 0);
130
+ assert.equal(remaining(s, 0), 1);
131
+ assert.ok(Math.abs(remaining(s, DEFAULTS.clockStartMs / 2) - 0.5) < 0.01);
132
+ assert.equal(remaining(s, DEFAULTS.clockStartMs), 0);
133
+ assert.equal(remaining(s, DEFAULTS.clockStartMs * 5), 0, 'the bar went negative');
134
+ assert.equal(remaining({...s, current: null}, 0), 0);
135
+ });
136
+
137
+ test('a perfect player is never punished by the clock', () => {
138
+ const {state} = playPerfect(40, 40);
139
+ assert.equal(state.lives, DEFAULTS.lives);
140
+ assert.equal(state.over, false);
141
+ assert.ok(state.score > 0);
142
+ });
143
+
144
+ test('the deal is varied, and all three lanes turn up in a real run', () => {
145
+ const {dealt} = playPerfect(13, 30);
146
+ const lanes = new Set(dealt.map((c) => c.needs));
147
+ assert.deepEqual([...lanes].sort(), [DECISION, INTAKE, REVIEW].sort());
148
+ assert.ok(new Set(dealt.map((c) => c.key)).size >= 4, 'the same few cases keep coming back');
149
+ });
150
+
151
+ test('the same seed deals the same cases, a different one does not', () => {
152
+ const a = playPerfect(21, 6).dealt.map((c) => c.key).join(',');
153
+ const b = playPerfect(21, 6).dealt.map((c) => c.key).join(',');
154
+ const c = playPerfect(22, 6).dealt.map((c) => c.key).join(',');
155
+ assert.equal(a, b);
156
+ assert.notEqual(a, c);
157
+ });
158
+
159
+ test('the summary reads as a sentence in both locales', () => {
160
+ const s = {handled: 9, bestCombo: 4};
161
+ assert.match(summarise(s, 'en'), /9 cases on time · streak 4/);
162
+ assert.match(summarise(s, 'nl'), /9 zaken op tijd · reeks 4/);
163
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Deadline defender — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Dossiq's game. A case arrives with a legal deadline already running,
5
+ * and the only question is which step it goes to next. Send it to the
6
+ * right one before the clock runs out. Send it to the wrong one and
7
+ * you have moved a case backwards in front of a resident who can see
8
+ * their own file.
9
+ *
10
+ * One case at a time, on purpose: the stamp-rush board is six things
11
+ * at once, and two games with the same shape would be one game twice.
12
+ * Here the pressure is the clock on a single decision, which is the
13
+ * pressure the app is about.
14
+ *
15
+ * Time and randomness are injected. The component owns the clock.
16
+ */
17
+
18
+ export const INTAKE = 'intake';
19
+ export const REVIEW = 'review';
20
+ export const DECISION = 'decision';
21
+ export const LANES = [INTAKE, REVIEW, DECISION];
22
+
23
+ /* Each case is one of these situations. `needs` is the only correct
24
+ lane; the text is what the player reads to work that out, which is
25
+ why every situation reads as a sentence from a real case file. */
26
+ export const SITUATIONS = [
27
+ {key: 'permitReceived', needs: INTAKE},
28
+ {key: 'objectionReceived', needs: INTAKE},
29
+ {key: 'complaintReceived', needs: INTAKE},
30
+ {key: 'documentsComplete', needs: REVIEW},
31
+ {key: 'siteVisitDone', needs: REVIEW},
32
+ {key: 'adviceReturned', needs: REVIEW},
33
+ {key: 'assessmentDone', needs: DECISION},
34
+ {key: 'objectionAssessed', needs: DECISION},
35
+ {key: 'enforcementReady', needs: DECISION},
36
+ ];
37
+
38
+ export const DEFAULTS = {
39
+ lives: 3,
40
+ /* A case has to be read before it can be routed, so the first ones
41
+ get three seconds. The floor is the point where a fast reader can
42
+ still finish the sentence. */
43
+ clockStartMs: 3200,
44
+ clockFloorMs: 1300,
45
+ rampPerPoint: 3,
46
+ /* The beat between finishing one case and the next arriving. Long
47
+ enough to see what happened, short enough to keep the queue
48
+ breathing down your neck. */
49
+ gapMs: 320,
50
+ pointsPerCase: 10,
51
+ comboBonus: 2,
52
+ };
53
+
54
+ function mulberry32(seed) {
55
+ let a = seed >>> 0;
56
+ return function random() {
57
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
58
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
59
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
60
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
61
+ };
62
+ }
63
+
64
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
65
+ const cfg = {...DEFAULTS, ...config};
66
+ const state = {
67
+ cfg,
68
+ random: mulberry32(seed),
69
+ current: null,
70
+ nextAt: now,
71
+ score: 0,
72
+ lives: cfg.lives,
73
+ combo: 0,
74
+ bestCombo: 0,
75
+ handled: 0,
76
+ missed: 0,
77
+ misrouted: 0,
78
+ last: null,
79
+ over: false,
80
+ };
81
+ return state;
82
+ }
83
+
84
+ /** How long the current difficulty gives you to read and route. */
85
+ export function clockMs(state) {
86
+ const {cfg, score} = state;
87
+ return Math.max(cfg.clockFloorMs, cfg.clockStartMs - score * cfg.rampPerPoint);
88
+ }
89
+
90
+ function loseLife(state, reason) {
91
+ const lives = state.lives - 1;
92
+ return {
93
+ ...state,
94
+ lives,
95
+ combo: 0,
96
+ last: {result: reason, at: state.nextAt},
97
+ over: lives <= 0,
98
+ };
99
+ }
100
+
101
+ function deal(state, now) {
102
+ const pick = SITUATIONS[Math.floor(state.random() * SITUATIONS.length)];
103
+ /* The case number is cosmetic, but a case without one does not read
104
+ as a case. */
105
+ const year = 2026;
106
+ const seq = 100 + Math.floor(state.random() * 900);
107
+ return {
108
+ ...state,
109
+ current: {
110
+ ...pick,
111
+ id: `${year}-${seq}`,
112
+ bornAt: now,
113
+ expiresAt: now + clockMs(state),
114
+ },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Advance to `now`: let an unanswered case run out of time, then deal
120
+ * the next one once the gap has passed.
121
+ */
122
+ export function step(state, now) {
123
+ if (state.over) return state;
124
+ let next = state;
125
+
126
+ if (next.current && now >= next.current.expiresAt) {
127
+ next = {...loseLife(next, 'missed'), current: null, missed: next.missed + 1, nextAt: now + next.cfg.gapMs};
128
+ if (next.over) return next;
129
+ }
130
+
131
+ if (!next.current && now >= next.nextAt) {
132
+ next = deal(next, now);
133
+ }
134
+
135
+ return next;
136
+ }
137
+
138
+ /**
139
+ * Route the case on the desk to `lane`.
140
+ *
141
+ * Routing to the wrong step costs a life. There is no partial credit:
142
+ * a case in the wrong queue is not half-handled, it is lost until
143
+ * somebody notices.
144
+ */
145
+ export function route(state, lane, now) {
146
+ if (state.over || !state.current) return state;
147
+ if (!LANES.includes(lane)) return state;
148
+
149
+ const correct = state.current.needs === lane;
150
+ if (!correct) {
151
+ return {
152
+ ...loseLife(state, 'misrouted'),
153
+ current: null,
154
+ misrouted: state.misrouted + 1,
155
+ nextAt: now + state.cfg.gapMs,
156
+ };
157
+ }
158
+
159
+ const combo = state.combo + 1;
160
+ const gained = state.cfg.pointsPerCase + (combo - 1) * state.cfg.comboBonus;
161
+ return {
162
+ ...state,
163
+ current: null,
164
+ nextAt: now + state.cfg.gapMs,
165
+ score: state.score + gained,
166
+ combo,
167
+ bestCombo: Math.max(state.bestCombo, combo),
168
+ handled: state.handled + 1,
169
+ last: {result: 'handled', points: gained, at: now},
170
+ };
171
+ }
172
+
173
+ /** How much of the deadline is left, 1 to 0, for the countdown bar. */
174
+ export function remaining(state, now) {
175
+ if (!state.current) return 0;
176
+ const total = state.current.expiresAt - state.current.bornAt;
177
+ if (total <= 0) return 0;
178
+ const left = (state.current.expiresAt - now) / total;
179
+ return Math.min(1, Math.max(0, left));
180
+ }
181
+
182
+ /** The line that goes on the game-over card and into the post. */
183
+ export function summarise(state, locale = 'en') {
184
+ const n = (v) => Number(v || 0).toLocaleString(locale);
185
+ return locale === 'nl'
186
+ ? `${n(state.handled)} zaken op tijd · reeks ${n(state.bestCombo)}`
187
+ : `${n(state.handled)} cases on time · streak ${n(state.bestCombo)}`;
188
+ }
@@ -48,6 +48,10 @@
48
48
  * normally holds just the primary + secondary pair; a non-GitHub
49
49
  * tertiary still renders for compatibility.
50
50
  *
51
+ * The counter is hidden below MIN_DISPLAYED_DOWNLOADS (see
52
+ * ../../data/app-downloads), so a newly published app shows the
53
+ * GitHub chip on its own rather than a number that undersells it.
54
+ *
51
55
  * `background="cobalt"` paints the hero in a full-bleed cobalt panel
52
56
  * with white type — the product-page identity used on the
53
57
  * {slug}.conduction.nl landings. Default (undefined) keeps the
@@ -60,7 +64,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
60
64
  import HexBullet from '../primitives/HexBullet';
61
65
  import Button from '../primitives/Button';
62
66
  import {deriveStability} from '../../theme/brand.jsx';
63
- import {downloadsForApp, formatDownloads} from '../../data/app-downloads';
67
+ import {downloadsForApp, formatDownloads, showDownloads} from '../../data/app-downloads';
64
68
  import {APPS_REGISTRY, applicationCategoryFor} from '../../data/apps-registry';
65
69
  import AppGlyph, {hasAppGlyph} from '../AppGlyph/AppGlyph.jsx';
66
70
  import styles from './DetailHero.module.css';
@@ -98,6 +102,9 @@ export default function DetailHero({
98
102
  repoHref,
99
103
  }) {
100
104
  const dlCount = downloads != null ? downloads : (appId ? downloadsForApp(appId) : 0);
105
+ /* A count under MIN_DISPLAYED_DOWNLOADS renders nowhere: not as the
106
+ chip, not in the JSON-LD. See showDownloads() for why. */
107
+ const showDlCount = showDownloads(dlCount);
101
108
  /* GitHub repo link for the badge row. Priority: explicit `repoHref`
102
109
  prop → a GitHub-pointing tertiaryCta (the old "View on GitHub"
103
110
  ghost button, which this hero now renders as a meta-row chip
@@ -187,7 +194,7 @@ export default function DetailHero({
187
194
  };
188
195
  if (taglineText) schema.description = taglineText;
189
196
  if (resolvedVersion) schema.softwareVersion = resolvedVersion.replace(/^v/, '');
190
- if (dlCount > 0) {
197
+ if (showDlCount) {
191
198
  /* Surface install count as InteractionCounter rather than
192
199
  aggregateRating; downloads are not reviews. */
193
200
  schema.interactionStatistic = {
@@ -266,7 +273,7 @@ export default function DetailHero({
266
273
 
267
274
  <div className={styles.headInner}>
268
275
  <div className={styles.copy}>
269
- {(resolvedStatus || resolvedVersion || locales || dlCount > 0 || resolvedRepoHref) && (
276
+ {(resolvedStatus || resolvedVersion || locales || showDlCount || resolvedRepoHref) && (
270
277
  <div className={styles.badgeRow}>
271
278
  {resolvedStatus && (
272
279
  <span className={styles.badge}>
@@ -276,7 +283,7 @@ export default function DetailHero({
276
283
  )}
277
284
  {resolvedVersion && <span className={[styles.badge, styles.versionBadge].join(' ')}>{resolvedVersion}</span>}
278
285
  {locales && <span className={[styles.badge, styles.versionBadge].join(' ')}>{locales}</span>}
279
- {dlCount > 0 && (() => {
286
+ {showDlCount && (() => {
280
287
  /* The downloads counter links to the repo when one
281
288
  resolves; a plain chip otherwise. */
282
289
  const DlTag = resolvedRepoHref ? 'a' : 'span';
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DetailHero.downloads.test.js — the per-app download counter only
3
+ * appears once an app has real traction.
4
+ *
5
+ * A freshly published app sits on a handful of downloads for weeks,
6
+ * and "5 downloads" beside the title argues against the app it is
7
+ * meant to recommend. The counter is therefore gated on
8
+ * MIN_DISPLAYED_DOWNLOADS, and the gate covers the schema.org
9
+ * InteractionCounter as well: a number the page hides must not reach
10
+ * search results through the structured data.
11
+ *
12
+ * Renders the real <DetailHero> JSX to static markup with the same
13
+ * esbuild-bundle-then-renderToStaticMarkup technique the other render
14
+ * tests use (AppMock, AiDisclosure). Docusaurus-only modules are
15
+ * stubbed, since this runs outside a Docusaurus build.
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const test = require('node:test');
21
+ const {before, after} = test;
22
+ const assert = require('node:assert/strict');
23
+ const path = require('node:path');
24
+ const fs = require('node:fs/promises');
25
+ const {build} = require('esbuild');
26
+ const React = require('react');
27
+ const {renderToStaticMarkup} = require('react-dom/server');
28
+
29
+ const {MIN_DISPLAYED_DOWNLOADS} = require('../../../data/app-downloads.js');
30
+
31
+ const COMPONENT = path.resolve(__dirname, '..', 'DetailHero.jsx');
32
+ const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
33
+ const SCRATCH = path.join(PRESET_ROOT, '.tmp-detail-hero-test');
34
+
35
+ const cssModuleStub = {
36
+ name: 'css-module-stub',
37
+ setup(b) {
38
+ b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
39
+ b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
40
+ contents: 'export default new Proxy({}, {get: (_, p) => p});',
41
+ loader: 'js',
42
+ }));
43
+ },
44
+ };
45
+
46
+ /* <Head> renders its children inline here, which is what lets the test
47
+ read the JSON-LD out of the same markup string. */
48
+ const docusaurusStub = {
49
+ name: 'docusaurus-stub',
50
+ setup(b) {
51
+ b.onResolve({filter: /^@docusaurus\/Head$/}, () => ({path: 'docusaurus-head', namespace: 'docusaurus-stub'}));
52
+ b.onResolve({filter: /^@docusaurus\/useDocusaurusContext$/}, () => ({path: 'docusaurus-context', namespace: 'docusaurus-stub'}));
53
+ b.onResolve({filter: /^@docusaurus\/Link$/}, () => ({path: 'docusaurus-link', namespace: 'docusaurus-stub'}));
54
+ b.onResolve({filter: /^@docusaurus\/useBaseUrl$/}, () => ({path: 'docusaurus-base-url', namespace: 'docusaurus-stub'}));
55
+ b.onResolve({filter: /^@docusaurus\/Translate$/}, () => ({path: 'docusaurus-translate', namespace: 'docusaurus-stub'}));
56
+ b.onLoad({filter: /^docusaurus-head$/, namespace: 'docusaurus-stub'}, () => ({
57
+ contents: `import React from 'react';
58
+ export default function Head({children}) { return React.createElement(React.Fragment, null, children); }`,
59
+ loader: 'jsx',
60
+ }));
61
+ b.onLoad({filter: /^docusaurus-context$/, namespace: 'docusaurus-stub'}, () => ({
62
+ contents: `export default function useDocusaurusContext() {
63
+ return {i18n: {currentLocale: 'en'}, siteConfig: {url: 'https://conduction.nl', baseUrl: '/'}};
64
+ }`,
65
+ loader: 'js',
66
+ }));
67
+ b.onLoad({filter: /^docusaurus-link$/, namespace: 'docusaurus-stub'}, () => ({
68
+ contents: `import React from 'react';
69
+ export default function Link({to, href, children, ...rest}) {
70
+ return React.createElement('a', {href: to || href, ...rest}, children);
71
+ }`,
72
+ loader: 'jsx',
73
+ }));
74
+ b.onLoad({filter: /^docusaurus-base-url$/, namespace: 'docusaurus-stub'}, () => ({
75
+ contents: `export default function useBaseUrl(p) { return p; }`,
76
+ loader: 'js',
77
+ }));
78
+ b.onLoad({filter: /^docusaurus-translate$/, namespace: 'docusaurus-stub'}, () => ({
79
+ contents: `import React from 'react';
80
+ export function translate(o, v) { return o.message; }
81
+ export default function Translate({children}) { return React.createElement(React.Fragment, null, children); }`,
82
+ loader: 'jsx',
83
+ }));
84
+ },
85
+ };
86
+
87
+ let DetailHero;
88
+
89
+ before(async () => {
90
+ await fs.mkdir(SCRATCH, {recursive: true});
91
+ const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
92
+ const outFile = path.join(tmpDir, 'bundle.cjs');
93
+ await build({
94
+ entryPoints: [COMPONENT],
95
+ outfile: outFile,
96
+ bundle: true,
97
+ format: 'cjs',
98
+ jsx: 'automatic',
99
+ jsxImportSource: 'react',
100
+ tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
101
+ platform: 'node',
102
+ external: ['react'],
103
+ plugins: [cssModuleStub, docusaurusStub],
104
+ logLevel: 'warning',
105
+ });
106
+ delete require.cache[require.resolve(outFile)];
107
+ DetailHero = require(outFile).default;
108
+ });
109
+
110
+ after(async () => {
111
+ await fs.rm(SCRATCH, {recursive: true, force: true});
112
+ });
113
+
114
+ function render(props) {
115
+ return renderToStaticMarkup(
116
+ React.createElement(DetailHero, {title: 'Buildiq', appId: 'openbuild', ...props}),
117
+ );
118
+ }
119
+
120
+ /* The counter and the "View on GitHub" chip share the downloadsBadge
121
+ class, so the counter is identified by its own data attribute and by
122
+ the word it prints. Asserting on the shared class would fail on a
123
+ page that correctly shows only the GitHub chip. */
124
+ test('a count under the threshold renders no counter at all', () => {
125
+ const html = render({downloads: MIN_DISPLAYED_DOWNLOADS - 1});
126
+ assert.doesNotMatch(html, /downloads</, 'the counter text is on the page');
127
+ assert.doesNotMatch(html, /data-app-downloads/, 'the counter element is on the page');
128
+ assert.match(html, /View on GitHub/, 'the GitHub chip went down with the counter');
129
+ });
130
+
131
+ test('zero downloads renders no counter', () => {
132
+ assert.doesNotMatch(render({downloads: 0}), /data-app-downloads/);
133
+ });
134
+
135
+ test('a count on the threshold renders the counter, formatted', () => {
136
+ const html = render({downloads: MIN_DISPLAYED_DOWNLOADS});
137
+ assert.match(html, /1,000 downloads/);
138
+ assert.match(html, /data-app-downloads/);
139
+ });
140
+
141
+ test('a large count keeps rendering', () => {
142
+ assert.match(render({downloads: 9079}), /9,079 downloads/);
143
+ });
144
+
145
+ test('the structured data follows the chip, so a hidden number is not published', () => {
146
+ const low = render({downloads: MIN_DISPLAYED_DOWNLOADS - 1});
147
+ assert.doesNotMatch(low, /InteractionCounter/, 'JSON-LD advertises a count the page hides');
148
+
149
+ const high = render({downloads: MIN_DISPLAYED_DOWNLOADS});
150
+ assert.match(high, /InteractionCounter/);
151
+ assert.match(high, /"userInteractionCount":1000/);
152
+ });
153
+
154
+ test('the rest of the badge row survives a suppressed counter', () => {
155
+ /* The row itself is conditional, so hiding the counter must not take
156
+ the version badge or the GitHub chip down with it. */
157
+ const html = render({downloads: 5, version: 'v0.10'});
158
+ assert.match(html, /v0\.10/);
159
+ assert.match(html, /View on GitHub/);
160
+ });
@@ -24,6 +24,11 @@
24
24
  * date="2026-05-05"
25
25
  * thumbnail={{ icon: <svg>...</svg> }}
26
26
  * />
27
+ *
28
+ * `visual={<AppMock app="openregister" />}` puts a node in the
29
+ * right-hand column instead of the hex thumbnail, for a card whose
30
+ * subject is better shown than symbolised. The satellite hexes stand
31
+ * down when it does.
27
32
  */
28
33
 
29
34
  import React from 'react';
@@ -52,6 +57,7 @@ export default function FeaturedCard({
52
57
  dateLabel,
53
58
  locale,
54
59
  thumbnail,
60
+ visual,
55
61
  accent = 'orange',
56
62
  contentType,
57
63
  durationMinutes,
@@ -98,6 +104,11 @@ export default function FeaturedCard({
98
104
  const metaBits = [readWatch, moduleLabel, moduleSlug ? (moduleTitle || moduleSlug) : null]
99
105
  .filter(Boolean);
100
106
  const Tag = href ? 'a' : 'div';
107
+ /* `visual` replaces the hex thumbnail with an arbitrary node (an
108
+ AppMock, a ThemeSeamMock, a diagram). The satellite hexes are
109
+ dropped with it: they frame a hex, and they read as clutter
110
+ around a rectangular frame. */
111
+ const hasVisual = Boolean(visual);
101
112
  const composed = [styles.card, className].filter(Boolean).join(' ');
102
113
  const thumbProps = thumbnail || {};
103
114
 
@@ -150,7 +161,8 @@ export default function FeaturedCard({
150
161
  )}
151
162
  </div>
152
163
 
153
- <div className={styles.visual} aria-hidden="true">
164
+ <div className={[styles.visual, hasVisual && styles.visualNode].filter(Boolean).join(' ')} aria-hidden="true">
165
+ {hasVisual ? visual : (<>
154
166
  {thumbProps.src
155
167
  ? <HexThumbnail size="xl" tone="cobalt" src={thumbProps.src} alt={thumbProps.alt} />
156
168
  : <HexThumbnail size="xl" tone={thumbProps.tone || 'cobalt'}>{thumbProps.icon}</HexThumbnail>}
@@ -161,6 +173,7 @@ export default function FeaturedCard({
161
173
  styles.s3,
162
174
  accent === 'orange' ? styles.s3Orange : styles.s3Cobalt,
163
175
  ].join(' ')} />
176
+ </>)}
164
177
  </div>
165
178
  </Tag>
166
179
  );
@@ -53,6 +53,11 @@
53
53
  line-height: 1.55;
54
54
  margin: 0 0 var(--space-6);
55
55
  max-width: 50ch;
56
+ /* `pre-line` keeps the paragraph breaks a summary written as a YAML
57
+ block scalar carries, while still collapsing runs of spaces. A
58
+ one-line summary is unaffected: with no newlines in the source
59
+ there is nothing to preserve. */
60
+ white-space: pre-line;
56
61
  }
57
62
 
58
63
  .metaBits {
@@ -109,6 +114,11 @@
109
114
  min-height: 300px;
110
115
  }
111
116
 
117
+ /* A passed-in visual gets the column to itself: no hex to frame, and
118
+ the node brings its own aspect ratio. */
119
+ .visualNode { display: block; min-height: 0; }
120
+ .visualNode > * { width: 100%; }
121
+
112
122
  .satellite {
113
123
  position: absolute;
114
124
  width: 64px;