@conduction/docusaurus-preset 3.28.0 → 3.32.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 (33) hide show
  1. package/MISSING_COMPONENTS.md +6 -2
  2. package/package.json +4 -4
  3. package/src/components/AppMock/AppMock.jsx +16 -0
  4. package/src/components/AppMock/variants/AppVersionsMock.jsx +83 -0
  5. package/src/components/AppMock/variants/DoriathMock.jsx +79 -0
  6. package/src/components/AppMock/variants/HermiqMock.jsx +71 -0
  7. package/src/components/AppMock/variants/HrmqMock.jsx +77 -0
  8. package/src/components/AppMock/variants/PlanixMock.jsx +80 -0
  9. package/src/components/AppMock/variants/PortaliqMock.jsx +63 -0
  10. package/src/components/AppMock/variants/ScholiqMock.jsx +74 -0
  11. package/src/components/AppMock/variants/ShillinqMock.jsx +90 -0
  12. package/src/components/BuildMock/BuildMock.jsx +101 -0
  13. package/src/components/BuildMock/BuildMock.module.css +290 -0
  14. package/src/components/CookieCli/CookieCli.jsx +197 -24
  15. package/src/components/CookieCli/CookieCli.module.css +139 -1
  16. package/src/components/CookieCli/shell.js +161 -0
  17. package/src/components/CookieCli/spaceInvaders.js +359 -0
  18. package/src/components/CookieCli/spaceInvaders.test.js +112 -0
  19. package/src/components/DetailHero/DetailHero.jsx +68 -16
  20. package/src/components/DetailHero/DetailHero.module.css +31 -1
  21. package/src/components/FlowMock/FlowMock.jsx +151 -0
  22. package/src/components/FlowMock/FlowMock.module.css +164 -0
  23. package/src/components/KanbanMock/KanbanMock.jsx +119 -0
  24. package/src/components/KanbanMock/KanbanMock.module.css +233 -0
  25. package/src/components/LeafMock/LeafMock.jsx +173 -0
  26. package/src/components/LeafMock/LeafMock.module.css +237 -0
  27. package/src/components/RotatingCards/RotatingCards.module.css +5 -2
  28. package/src/components/Showcase/Showcase.module.css +5 -2
  29. package/src/components/WidgetShelf/WidgetShelf.jsx +52 -13
  30. package/src/components/WidgetShelf/WidgetShelf.module.css +84 -5
  31. package/src/components/index.js +4 -0
  32. package/src/components/primitives/SectionHead.module.css +6 -0
  33. package/src/data/app-glyphs.json +4 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Conduction Space Invaders — the game behind `game.exe` in the cookie CLI.
3
+ *
4
+ * Ported from the kit specimen at preview/cookie-cli.html, where this had
5
+ * lived since the component was designed. The React <CookieCli /> shipped
6
+ * without it for a while, which meant GameModal advertised a game
7
+ * ("Hex-vaders · cookie CLI") that no site actually had.
8
+ *
9
+ * Kept as a plain class rather than React state on purpose. It ticks every
10
+ * 90ms and repaints a 48x14 character grid, which is 672 cells per frame,
11
+ * eleven times a second. Reconciling that through React would do a great
12
+ * deal of work to arrive at the same string. The class builds the frame as
13
+ * HTML and hands it to the caller, which writes it to a ref.
14
+ *
15
+ * Cobalt UFOs, KNVB-orange lasers, per the kit.
16
+ *
17
+ * The engine owns no DOM and reads no globals. It reports frames through
18
+ * `onFrame` and asks to be dismissed through `onExit`, so the React
19
+ * component stays the only thing that touches the document.
20
+ */
21
+
22
+ const COLS = 48;
23
+ const ROWS = 14;
24
+
25
+ /* The event the shared GameModal listens for. Named `connext:` because the
26
+ modal has used that prefix since the ConNext era; it is a brand-internal
27
+ identifier kept for compatibility, not a live brand reference. */
28
+ const GAME_END_EVENT = 'connext:gameend';
29
+ const GAME_REPLAY_EVENT = 'connext:gamereplay';
30
+ const GAME_ID = 'invaders';
31
+
32
+ function escapeHtml(s) {
33
+ return String(s).replace(/[&<>"']/g, (c) => ({
34
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
35
+ }[c]));
36
+ }
37
+
38
+ export default class SpaceInvaders {
39
+ /**
40
+ * @param {object} opts
41
+ * @param {function} opts.onFrame - called with {screen, hud, footer} HTML each repaint
42
+ * @param {function} opts.onExit - called when the player quits (ESC / Q)
43
+ */
44
+ constructor({onFrame, onExit} = {}) {
45
+ this.onFrame = typeof onFrame === 'function' ? onFrame : () => {};
46
+ this.onExit = typeof onExit === 'function' ? onExit : () => {};
47
+
48
+ this.score = 0;
49
+ this.wave = 1;
50
+ this.hits = 0; // bombs that landed on you; costs nothing but pride
51
+ this.cleared = 0; // waves shot down
52
+ this.landed = 0; // waves that reached the floor
53
+ this.hitFlash = 0; // ticks left of the impact glyph
54
+ this.paused = false;
55
+ this.player = {x: Math.floor(COLS / 2) - 1};
56
+ this.lasers = [];
57
+ this.bombs = [];
58
+ this.ufos = [];
59
+ this.ufoDir = 1;
60
+ this.ufoSpeed = 9;
61
+ this.tickCount = 0;
62
+ this.graceTicks = 18; // ~1.6s before bombs start dropping
63
+
64
+ this.onReplay = (e) => {
65
+ if (e.detail && e.detail.id === GAME_ID) this.restart();
66
+ };
67
+ if (typeof window !== 'undefined') {
68
+ window.addEventListener(GAME_REPLAY_EVENT, this.onReplay);
69
+ }
70
+
71
+ this.spawnUfos();
72
+ this.interval = setInterval(() => this.tick(), 90);
73
+ this.render();
74
+ }
75
+
76
+ spawnUfos() {
77
+ this.ufos = [];
78
+ const rows = 3, cols = 6;
79
+ const spacing = 7;
80
+ const xStart = Math.floor((COLS - (cols - 1) * spacing - 3) / 2);
81
+ for (let r = 0; r < rows; r++) {
82
+ for (let c = 0; c < cols; c++) {
83
+ this.ufos.push({x: xStart + c * spacing, y: 1 + r * 2, alive: true, exploding: 0, row: r});
84
+ }
85
+ }
86
+ }
87
+
88
+ onKey(e) {
89
+ /* The old game-over branch is gone with game over itself. [R] used to
90
+ live only in that branch, which would have left it dead: reachable
91
+ by a key hint that never appeared, on a screen that never showed.
92
+ It now restarts at any time. */
93
+ switch (e.key) {
94
+ case 'r': case 'R':
95
+ this.restart(); e.preventDefault(); return;
96
+ default: break;
97
+ }
98
+ switch (e.key) {
99
+ case 'ArrowLeft': case 'a': case 'A':
100
+ this.player.x = Math.max(0, this.player.x - 2);
101
+ e.preventDefault(); break;
102
+ case 'ArrowRight': case 'd': case 'D':
103
+ this.player.x = Math.min(COLS - 3, this.player.x + 2);
104
+ e.preventDefault(); break;
105
+ case ' ': case 'ArrowUp': case 'w': case 'W':
106
+ this.fire(); e.preventDefault(); break;
107
+ case 'Escape': case 'q': case 'Q':
108
+ this.quit(); e.preventDefault(); break;
109
+ case 'p': case 'P':
110
+ this.paused = !this.paused; e.preventDefault(); this.render(); break;
111
+ default: break;
112
+ }
113
+ }
114
+
115
+ fire() {
116
+ if (this.lasers.length >= 3) return;
117
+ this.lasers.push({x: this.player.x + 1, y: ROWS - 2});
118
+ }
119
+
120
+ quit() {
121
+ this.stop();
122
+ this.onExit();
123
+ }
124
+
125
+ stop() {
126
+ clearInterval(this.interval);
127
+ this.interval = null;
128
+ if (typeof window !== 'undefined') {
129
+ window.removeEventListener(GAME_REPLAY_EVENT, this.onReplay);
130
+ }
131
+ }
132
+
133
+ restart() {
134
+ this.score = 0;
135
+ this.wave = 1;
136
+ this.hits = 0;
137
+ this.cleared = 0;
138
+ this.landed = 0;
139
+ this.hitFlash = 0;
140
+ this._endEmitted = false;
141
+ this.player = {x: Math.floor(COLS / 2) - 1};
142
+ this.lasers = [];
143
+ this.bombs = [];
144
+ this.ufoSpeed = 9;
145
+ this.tickCount = 0;
146
+ this.graceTicks = 18;
147
+ this.spawnUfos();
148
+ this.render();
149
+ }
150
+
151
+ /* Advance to a fresh wave, whether the last one was shot down or landed.
152
+ Clears bombs so the new wave does not open under incoming fire, and
153
+ restores the grace period so there is a beat before it resumes. */
154
+ nextWave() {
155
+ this.wave += 1;
156
+ this.spawnUfos();
157
+ this.ufoSpeed = Math.max(3, 9 - this.wave);
158
+ this.bombs = [];
159
+ this.graceTicks = 12;
160
+ this.render();
161
+ }
162
+
163
+ tick() {
164
+ if (this.paused) return;
165
+ this.tickCount++;
166
+
167
+ // lasers up every tick
168
+ this.lasers.forEach((l) => { l.y -= 1; });
169
+ this.lasers = this.lasers.filter((l) => l.y >= 0);
170
+
171
+ // bombs down every 2 ticks
172
+ if (this.tickCount % 2 === 0) {
173
+ this.bombs.forEach((b) => { b.y += 1; });
174
+ this.bombs = this.bombs.filter((b) => b.y < ROWS);
175
+ }
176
+
177
+ // UFOs march
178
+ if (this.tickCount % this.ufoSpeed === 0) {
179
+ const alive = this.ufos.filter((u) => u.alive);
180
+ if (alive.length) {
181
+ const minX = Math.min(...alive.map((u) => u.x));
182
+ const maxX = Math.max(...alive.map((u) => u.x));
183
+ if (this.ufoDir === 1 && maxX + 3 >= COLS) {
184
+ this.ufoDir = -1;
185
+ alive.forEach((u) => { u.y += 1; });
186
+ } else if (this.ufoDir === -1 && minX <= 0) {
187
+ this.ufoDir = 1;
188
+ alive.forEach((u) => { u.y += 1; });
189
+ } else {
190
+ alive.forEach((u) => { u.x += this.ufoDir; });
191
+ }
192
+ }
193
+ }
194
+
195
+ // random bombs, after a short grace period so the player can settle in
196
+ if (this.graceTicks > 0) {
197
+ this.graceTicks--;
198
+ } else {
199
+ const aliveUfos = this.ufos.filter((u) => u.alive);
200
+ const bombChance = 0.035 + (this.wave - 1) * 0.01;
201
+ if (aliveUfos.length && this.bombs.length < 4 && Math.random() < bombChance) {
202
+ const u = aliveUfos[Math.floor(Math.random() * aliveUfos.length)];
203
+ this.bombs.push({x: u.x + 1, y: u.y + 1});
204
+ }
205
+ }
206
+
207
+ // laser vs ufo
208
+ this.lasers.forEach((l) => {
209
+ this.ufos.forEach((u) => {
210
+ if (!u.alive) return;
211
+ if (l.y === u.y && l.x >= u.x && l.x <= u.x + 2) {
212
+ u.alive = false;
213
+ u.exploding = 2;
214
+ l.dead = true;
215
+ this.score += 10 * (3 - u.row);
216
+ const remaining = this.ufos.filter((uu) => uu.alive).length;
217
+ this.ufoSpeed = Math.max(2, 8 - Math.floor((18 - remaining) / 3));
218
+ }
219
+ });
220
+ });
221
+ this.lasers = this.lasers.filter((l) => !l.dead);
222
+
223
+ this.ufos.forEach((u) => { if (u.exploding > 0) u.exploding -= 1; });
224
+
225
+ /* Bombs vs player. You cannot die here.
226
+ This is an easter egg in a cookie banner, not an arcade cabinet:
227
+ ending someone's game with GAME OVER while they were only trying to
228
+ set a cookie preference is a bad trade. A hit still lands and still
229
+ reads as a hit, it just costs a moment of shield flash rather than a
230
+ life. Bombs stay dangerous-looking and become harmless. */
231
+ this.bombs.forEach((b) => {
232
+ if (b.y === ROWS - 1 && b.x >= this.player.x && b.x <= this.player.x + 2) {
233
+ b.dead = true;
234
+ this.hitFlash = 4; // ticks the player renders as an impact
235
+ this.hits += 1;
236
+ }
237
+ });
238
+ this.bombs = this.bombs.filter((b) => !b.dead);
239
+ if (this.hitFlash > 0) this.hitFlash -= 1;
240
+
241
+ /* UFOs reaching the bottom used to end the game outright, regardless of
242
+ lives. Now the wave simply lands and a fresh one spawns, so the run
243
+ continues. Without this the one unavoidable death would still be
244
+ reachable by standing still. */
245
+ if (this.ufos.some((u) => u.alive && u.y >= ROWS - 1)) {
246
+ this.landed += 1;
247
+ this.nextWave();
248
+ return;
249
+ }
250
+
251
+ // wave clear
252
+ if (this.ufos.every((u) => !u.alive && u.exploding === 0)) {
253
+ this.cleared += 1;
254
+ this.nextWave();
255
+
256
+ /* Tell the shared GameModal the game was found, so it records
257
+ discovery and score in its cross-site progress cookie.
258
+ This used to fire on game over. Since there is no game over any
259
+ more, clearing the first wave is the moment: without moving it the
260
+ event would never fire at all and the game would stay permanently
261
+ undiscovered in the modal, which is a silent regression rather than
262
+ a visible one. Once per run. */
263
+ if (!this._endEmitted && typeof window !== 'undefined') {
264
+ this._endEmitted = true;
265
+ window.dispatchEvent(new CustomEvent(GAME_END_EVENT, {
266
+ detail: {
267
+ id: GAME_ID,
268
+ won: true,
269
+ score: this.score,
270
+ summary: `${this.score} pts · wave ${this.wave}`,
271
+ },
272
+ }));
273
+ }
274
+ }
275
+
276
+ this.render();
277
+ }
278
+
279
+ render() {
280
+ const grid = [];
281
+ const meta = [];
282
+ for (let y = 0; y < ROWS; y++) {
283
+ grid.push(Array(COLS).fill(' '));
284
+ meta.push(Array(COLS).fill(''));
285
+ }
286
+ const set = (y, x, c, t) => {
287
+ if (y >= 0 && y < ROWS && x >= 0 && x < COLS) { grid[y][x] = c; meta[y][x] = t; }
288
+ };
289
+
290
+ this.ufos.forEach((u) => {
291
+ if (u.alive) {
292
+ set(u.y, u.x, '<', 'ufo'); set(u.y, u.x + 1, 'O', 'ufo'); set(u.y, u.x + 2, '>', 'ufo');
293
+ } else if (u.exploding > 0) {
294
+ set(u.y, u.x, '*', 'boom'); set(u.y, u.x + 1, '#', 'boom'); set(u.y, u.x + 2, '*', 'boom');
295
+ }
296
+ });
297
+
298
+ this.lasers.forEach((l) => set(l.y, l.x, '|', 'laser'));
299
+ this.bombs.forEach((b) => set(b.y, b.x, '*', 'bomb'));
300
+
301
+ /* The ship, or a brief impact burst when something just hit it. The
302
+ flash is the whole feedback for taking a hit now that it costs
303
+ nothing, so without it bombs would land with no effect at all and
304
+ the game would feel broken rather than forgiving. */
305
+ if (this.hitFlash > 0) {
306
+ set(ROWS - 1, this.player.x, '*', 'boom');
307
+ set(ROWS - 1, this.player.x + 1, '#', 'boom');
308
+ set(ROWS - 1, this.player.x + 2, '*', 'boom');
309
+ } else {
310
+ set(ROWS - 1, this.player.x, '<', 'player');
311
+ set(ROWS - 1, this.player.x + 1, 'A', 'player');
312
+ set(ROWS - 1, this.player.x + 2, '>', 'player');
313
+ }
314
+
315
+ /* Coalesce equal-styled runs into one span per run instead of one per
316
+ character. A row is 48 cells but usually only a handful of runs. */
317
+ let screen = '';
318
+ for (let y = 0; y < ROWS; y++) {
319
+ let line = '';
320
+ let runT = '', runS = '';
321
+ const flush = () => {
322
+ if (!runS) return;
323
+ line += runT ? `<span class="g-${runT}">${escapeHtml(runS)}</span>` : escapeHtml(runS);
324
+ runS = '';
325
+ };
326
+ for (let x = 0; x < COLS; x++) {
327
+ if (meta[y][x] === runT) runS += grid[y][x];
328
+ else { flush(); runT = meta[y][x]; runS = grid[y][x]; }
329
+ }
330
+ flush();
331
+ screen += `${line}\n`;
332
+ }
333
+
334
+ /* LIVES is gone, because there is nothing to count down. SHIELD says
335
+ the same thing the three little ships used to say (you are fine)
336
+ without implying a budget that can run out. HITS is there so the
337
+ bombs still mean something. */
338
+ const hud = `
339
+ <span class="hud-label">SCORE</span> <span class="hud-val g-laser">${String(this.score).padStart(4, '0')}</span>
340
+ <span class="hud-label">SHIELD</span> <span class="hud-val g-player">${escapeHtml('∞')}</span>
341
+ <span class="hud-label">WAVE</span> <span class="hud-val">${this.wave}</span>
342
+ <span class="hud-label">HITS</span> <span class="hud-val">${this.hits}</span>
343
+ <span style="margin-left:auto" class="hud-label">conduction · space-invaders.exe</span>
344
+ `;
345
+
346
+ let footer;
347
+ if (this.paused) {
348
+ footer = '<span class="warn">-- paused -- press [P] to resume</span>';
349
+ } else if (this.graceTicks > 0) {
350
+ footer = '<span class="ok">-- READY --</span> <span class="cmt">defend the cookies. fire orange lasers at the cobalt UFOs.</span>';
351
+ } else {
352
+ footer = '<span class="cmt"># [← →] move &nbsp; [space] fire orange laser &nbsp; [P] pause &nbsp; [ESC] exit</span>';
353
+ }
354
+
355
+ this.onFrame({screen, hud, footer});
356
+ }
357
+ }
358
+
359
+ export {COLS, ROWS, GAME_ID, GAME_END_EVENT};
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The one rule this game has: you cannot lose it.
3
+ *
4
+ * It is an easter egg inside a cookie banner. Someone opened that banner to
5
+ * set a preference, so ending their game with GAME OVER is a worse outcome
6
+ * than letting them play forever. Both original death paths are covered
7
+ * here, because they were separate: bombs draining lives, and the swarm
8
+ * reaching the floor, which ended the run outright no matter how many lives
9
+ * were left.
10
+ *
11
+ * These assertions were checked against the previous engine before being
12
+ * committed: nine of the ten fail on it. A test for the absence of
13
+ * behaviour is worth very little until you have watched it fail.
14
+ *
15
+ * Runs under `npm test` (node --test) with no DOM: the engine guards its
16
+ * window access, and stop() lets the tick be driven by hand instead of a
17
+ * timer.
18
+ */
19
+
20
+ import test from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import SpaceInvaders, {ROWS} from './spaceInvaders.js';
23
+
24
+ function newGame() {
25
+ const frames = [];
26
+ const game = new SpaceInvaders({
27
+ onFrame: (f) => frames.push(f),
28
+ onExit: () => assert.fail('the game exited on its own'),
29
+ });
30
+ game.stop(); // drive tick() manually
31
+ return {game, frames};
32
+ }
33
+
34
+ test('bombs landing on the player never end the run', () => {
35
+ const {game} = newGame();
36
+ game.graceTicks = 0;
37
+ /* Drop each bomb one row above the player and tick twice, which is how a
38
+ bomb actually arrives: it descends every other tick, and the collision
39
+ check runs in the same tick as the move. Injecting one directly onto
40
+ the player's row instead tests nothing, because on a move tick it
41
+ advances to ROWS and is filtered as off-screen before the check. */
42
+ for (let i = 0; i < 6; i++) {
43
+ game.bombs.push({x: game.player.x + 1, y: ROWS - 2});
44
+ game.tick();
45
+ game.tick();
46
+ }
47
+ assert.ok(game.hits >= 6, `all six bombs should register a hit, got ${game.hits}`);
48
+ assert.equal(game.lives, undefined, 'there should be no lives counter to run out');
49
+ assert.equal(game.gameOver, undefined, 'there should be no game-over flag');
50
+ });
51
+
52
+ test('the swarm reaching the floor rolls into a new wave', () => {
53
+ const {game} = newGame();
54
+ const before = game.wave;
55
+ game.ufos.forEach((u) => { u.alive = true; u.y = ROWS - 1; });
56
+ game.tick();
57
+ assert.equal(game.wave, before + 1, 'the wave should advance');
58
+ assert.equal(game.ufos.filter((u) => u.alive).length, 18, 'a fresh swarm should spawn');
59
+ assert.equal(game.landed, 1, 'the landing should be counted');
60
+ });
61
+
62
+ test('clearing a wave advances it', () => {
63
+ const {game} = newGame();
64
+ const before = game.wave;
65
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
66
+ game.tick();
67
+ assert.equal(game.wave, before + 1);
68
+ assert.equal(game.cleared, 1);
69
+ });
70
+
71
+ test('no frame ever renders a GAME OVER', () => {
72
+ const {game, frames} = newGame();
73
+ game.graceTicks = 0;
74
+ for (let i = 0; i < 40; i++) {
75
+ game.bombs.push({x: game.player.x + 1, y: ROWS - 1});
76
+ game.tick();
77
+ }
78
+ const bad = frames.filter((f) => /GAME OVER|YOU WIN/i.test(f.footer));
79
+ assert.equal(bad.length, 0, 'the footer should never announce an ending');
80
+ assert.ok(frames.at(-1).hud.includes('∞'), 'the HUD should show an infinite shield');
81
+ });
82
+
83
+ /* GameModal records discovery from `connext:gameend`. That used to fire on
84
+ game over, so removing game over would have silently stopped the game
85
+ from ever being marked as found. It now fires on the first wave cleared,
86
+ and this is the test that keeps it honest. */
87
+ test('clearing the first wave still reports the game as found', () => {
88
+ const events = [];
89
+ const priorWindow = globalThis.window;
90
+ globalThis.window = {
91
+ addEventListener() {}, removeEventListener() {},
92
+ dispatchEvent: (e) => { events.push(e); return true; },
93
+ };
94
+ globalThis.CustomEvent = globalThis.CustomEvent || class { constructor(type, init) { this.type = type; this.detail = init?.detail; } };
95
+ try {
96
+ const game = new SpaceInvaders({onFrame() {}, onExit() {}});
97
+ game.stop();
98
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
99
+ game.tick();
100
+ const end = events.filter((e) => e.type === 'connext:gameend');
101
+ assert.equal(end.length, 1, 'exactly one gameend should fire');
102
+ assert.equal(end[0].detail.id, 'invaders');
103
+ assert.equal(end[0].detail.won, true);
104
+
105
+ // and only once per run
106
+ game.ufos.forEach((u) => { u.alive = false; u.exploding = 0; });
107
+ game.tick();
108
+ assert.equal(events.filter((e) => e.type === 'connext:gameend').length, 1);
109
+ } finally {
110
+ globalThis.window = priorWindow;
111
+ }
112
+ });
@@ -38,6 +38,16 @@
38
38
  * (or secondary) variant to the KNVB-orange accent. Reserved for
39
39
  * product pages with an orange-leaning brand identity (launchpad).
40
40
  *
41
+ * GitHub lives in the badge row, not the CTA row: the downloads
42
+ * counter links to the app's repository and a "View on GitHub" chip
43
+ * sits next to it (both new-tab). The URL comes from the `repoHref`
44
+ * prop when given; otherwise a GitHub-pointing `tertiaryCta` href is
45
+ * reused (and that tertiary is then dropped from the CTA row, so
46
+ * existing pages upgrade without edits); otherwise it falls back to
47
+ * https://github.com/ConductionNL/{appId}. The CTA row therefore
48
+ * normally holds just the primary + secondary pair; a non-GitHub
49
+ * tertiary still renders for compatibility.
50
+ *
41
51
  * `background="cobalt"` paints the hero in a full-bleed cobalt panel
42
52
  * with white type — the product-page identity used on the
43
53
  * {slug}.conduction.nl landings. Default (undefined) keeps the
@@ -85,8 +95,26 @@ export default function DetailHero({
85
95
  appId,
86
96
  downloads,
87
97
  background,
98
+ repoHref,
88
99
  }) {
89
100
  const dlCount = downloads != null ? downloads : (appId ? downloadsForApp(appId) : 0);
101
+ /* GitHub repo link for the badge row. Priority: explicit `repoHref`
102
+ prop → a GitHub-pointing tertiaryCta (the old "View on GitHub"
103
+ ghost button, which this hero now renders as a meta-row chip
104
+ instead of a third CTA) → the ConductionNL org default for the
105
+ appId. Both the downloads counter and the "View on GitHub" chip
106
+ link here, in a new tab. */
107
+ const tertiaryIsRepo = Boolean(
108
+ tertiaryCta && typeof tertiaryCta.href === 'string' && tertiaryCta.href.includes('github.com')
109
+ );
110
+ const resolvedRepoHref = repoHref
111
+ || (tertiaryIsRepo ? tertiaryCta.href : undefined)
112
+ || (appId ? `https://github.com/ConductionNL/${appId}` : undefined);
113
+ /* A GitHub tertiary CTA is "moved up top": it renders as the meta-row
114
+ chip and disappears from the CTA row, which then holds just the
115
+ primary + secondary pair. Any other tertiary (docs, demo, ...)
116
+ keeps rendering as before for compatibility. */
117
+ const renderedTertiaryCta = tertiaryIsRepo ? null : tertiaryCta;
90
118
  const hasIllustration = Boolean(illustration);
91
119
  /* Default the title mark to the canonical app glyph (the same logo
92
120
  served on identity.conduction.nl/apps) when the caller doesn't pass
@@ -238,7 +266,7 @@ export default function DetailHero({
238
266
 
239
267
  <div className={styles.headInner}>
240
268
  <div className={styles.copy}>
241
- {(resolvedStatus || resolvedVersion || locales || dlCount > 0) && (
269
+ {(resolvedStatus || resolvedVersion || locales || dlCount > 0 || resolvedRepoHref) && (
242
270
  <div className={styles.badgeRow}>
243
271
  {resolvedStatus && (
244
272
  <span className={styles.badge}>
@@ -248,17 +276,39 @@ export default function DetailHero({
248
276
  )}
249
277
  {resolvedVersion && <span className={[styles.badge, styles.versionBadge].join(' ')}>{resolvedVersion}</span>}
250
278
  {locales && <span className={[styles.badge, styles.versionBadge].join(' ')}>{locales}</span>}
251
- {dlCount > 0 && (
252
- <span
279
+ {dlCount > 0 && (() => {
280
+ /* The downloads counter links to the repo when one
281
+ resolves; a plain chip otherwise. */
282
+ const DlTag = resolvedRepoHref ? 'a' : 'span';
283
+ const dlLinkProps = resolvedRepoHref
284
+ ? {href: resolvedRepoHref, target: '_blank', rel: 'noopener noreferrer'}
285
+ : {};
286
+ return (
287
+ <DlTag
288
+ className={[styles.badge, styles.downloadsBadge].join(' ')}
289
+ title="Total release-asset downloads from GitHub. Updated weekdays at 09:00."
290
+ data-app-downloads={appId || ''}
291
+ {...dlLinkProps}
292
+ >
293
+ <svg className={styles.downloadIcon} viewBox="0 0 24 24" aria-hidden="true">
294
+ <path d="M12 3v12m0 0l-5-5m5 5l5-5M5 21h14"/>
295
+ </svg>
296
+ {formatDownloads(dlCount)} downloads
297
+ </DlTag>
298
+ );
299
+ })()}
300
+ {resolvedRepoHref && (
301
+ <a
253
302
  className={[styles.badge, styles.downloadsBadge].join(' ')}
254
- title="Total release-asset downloads from GitHub. Updated weekdays at 09:00."
255
- data-app-downloads={appId || ''}
303
+ href={resolvedRepoHref}
304
+ target="_blank"
305
+ rel="noopener noreferrer"
256
306
  >
257
- <svg className={styles.downloadIcon} viewBox="0 0 24 24" aria-hidden="true">
258
- <path d="M12 3v12m0 0l-5-5m5 5l5-5M5 21h14"/>
307
+ <svg className={styles.repoIcon} viewBox="0 0 16 16" aria-hidden="true">
308
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
259
309
  </svg>
260
- {formatDownloads(dlCount)} downloads
261
- </span>
310
+ View on GitHub
311
+ </a>
262
312
  )}
263
313
  </div>
264
314
  )}
@@ -280,7 +330,7 @@ export default function DetailHero({
280
330
  {tagline && <p className={styles.tagline}>{tagline}</p>}
281
331
  {intro && <div className={styles.intro}>{intro}</div>}
282
332
 
283
- {(primaryCta || secondaryCta || tertiaryCta) && (
333
+ {(primaryCta || secondaryCta || renderedTertiaryCta) && (
284
334
  <div className={styles.actions}>
285
335
  {primaryCta && (
286
336
  <Button
@@ -306,19 +356,21 @@ export default function DetailHero({
306
356
  {secondaryCta.label}
307
357
  </Button>
308
358
  )}
309
- {tertiaryCta && (
359
+ {renderedTertiaryCta && (
310
360
  /* On a cobalt-bg hero the default ghost variant
311
361
  (cobalt-700 text) disappears against the dark panel;
312
362
  auto-switch to on-dark-tertiary (white text + white
313
363
  border) so the CTA reads at parity with the primary
314
364
  and secondary buttons. Sites can still pass an
315
- explicit `variant` to opt out. */
365
+ explicit `variant` to opt out. GitHub-pointing
366
+ tertiaries never reach this row: they render as the
367
+ "View on GitHub" chip in the badge row instead. */
316
368
  <Button
317
- variant={tertiaryCta.variant || (background === 'cobalt' ? 'on-dark-tertiary' : 'ghost')}
318
- href={tertiaryCta.href}
319
- icon={tertiaryCta.icon}
369
+ variant={renderedTertiaryCta.variant || (background === 'cobalt' ? 'on-dark-tertiary' : 'ghost')}
370
+ href={renderedTertiaryCta.href}
371
+ icon={renderedTertiaryCta.icon}
320
372
  >
321
- {tertiaryCta.label} →
373
+ {renderedTertiaryCta.label} →
322
374
  </Button>
323
375
  )}
324
376
  </div>
@@ -37,7 +37,22 @@
37
37
  .bgCobalt .sep { color: var(--c-cobalt-300); }
38
38
  .bgCobalt .badge { color: var(--c-cobalt-100); }
39
39
  .bgCobalt .versionBadge { color: var(--c-cobalt-200); }
40
- .bgCobalt .downloadsBadge { color: white; }
40
+ /* On the cobalt hero the default chip (cobalt text on a cobalt-50
41
+ plate) inverts to full-opacity white text on a translucent white
42
+ chip. The 16%-white plate blends to #45649E over cobalt, which
43
+ keeps white text at 5.9:1 — above the 4.5:1 WCAG AA floor. The
44
+ earlier rule only recoloured the text and left the light plate,
45
+ which made the chip near-invisible on the blue panel. */
46
+ .bgCobalt .downloadsBadge {
47
+ color: white;
48
+ background: rgba(255, 255, 255, 0.16);
49
+ border-color: rgba(255, 255, 255, 0.35);
50
+ }
51
+ .bgCobalt a.downloadsBadge:hover {
52
+ color: white;
53
+ background: rgba(255, 255, 255, 0.26);
54
+ border-color: rgba(255, 255, 255, 0.5);
55
+ }
41
56
  .bgCobalt .title,
42
57
  .bgCobalt .titleText { color: white; }
43
58
  .bgCobalt .tagline { color: var(--c-cobalt-100); }
@@ -91,6 +106,9 @@
91
106
  }
92
107
  .versionBadge { color: var(--c-cobalt-400); }
93
108
 
109
+ /* Meta-row chip. Shared by the downloads counter and the "View on
110
+ GitHub" link; both are usually <a> elements pointing at the app's
111
+ repository, so the chip carries link resets + a hover state. */
94
112
  .downloadsBadge {
95
113
  color: var(--c-blue-cobalt);
96
114
  background: var(--c-cobalt-50);
@@ -98,6 +116,13 @@
98
116
  border-radius: var(--radius-sm);
99
117
  padding: 4px 10px;
100
118
  letter-spacing: 0.04em;
119
+ text-decoration: none;
120
+ transition: background 120ms ease, border-color 120ms ease;
121
+ }
122
+ a.downloadsBadge:hover {
123
+ color: var(--c-blue-cobalt);
124
+ background: var(--c-cobalt-100);
125
+ border-color: var(--c-cobalt-200);
101
126
  }
102
127
  .downloadIcon {
103
128
  width: 12px;
@@ -108,6 +133,11 @@
108
133
  stroke-linecap: round;
109
134
  stroke-linejoin: round;
110
135
  }
136
+ .repoIcon {
137
+ width: 12px;
138
+ height: 12px;
139
+ fill: currentColor;
140
+ }
111
141
 
112
142
  .title {
113
143
  font-size: 64px;