@conduction/docusaurus-preset 1.3.1 → 1.4.1

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/README.md CHANGED
@@ -60,6 +60,8 @@ The function returns a complete Docusaurus config with brand defaults pre-applie
60
60
  | `i18n` | | nl / en / de / fr, NL default | Override the brand-default i18n block. |
61
61
  | `navbar` | | locale dropdown + GitHub | Merged into the brand-default navbar object. |
62
62
  | `footer` | | three-column link grid + KvK/BTW copyright | Per-property fallback: any of `style` / `links` / `copyright` you omit keeps the brand default. Pass `footer: { links: [...] }` to swap columns and inherit the brand copyright. |
63
+ | `footerBrand` | | `{ wordmark: 'Conduction' }` | Overrides the wordmark in the canal-footer's left brand block. Pass `{ wordmark: 'X' }` for a single brand or `{ brands: [{wordmark, logo, href}, ...] }` for product pages co-built with a partner (rendered side by side). |
64
+ | `minigames` | | `true` | Set `false` to drop the brand canal-footer's boat-sinking + kade-cyclist mini-games on product pages. The static skyline + canal decoration are kept. |
63
65
  | `customCss` | | `[]` | Site-specific CSS, appended to `brand.css`. |
64
66
  | `presets` | | `[['classic', …]]` | Replaces the default preset list. |
65
67
  | `plugins` | | `[]` | Docusaurus plugins. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js"
6
6
  },
@@ -33,6 +33,10 @@
33
33
  * iconColor="var(--c-blue-cobalt)"
34
34
  * illustration={<AppMock app="mydash" />}
35
35
  * />
36
+ *
37
+ * Each cta object also accepts `tone: "orange"` to flip the primary
38
+ * (or secondary) variant to the KNVB-orange accent. Reserved for
39
+ * product pages with an orange-leaning brand identity (mydash).
36
40
  */
37
41
 
38
42
  import React from 'react';
@@ -127,8 +131,24 @@ export default function DetailHero({
127
131
 
128
132
  {(primaryCta || secondaryCta || tertiaryCta) && (
129
133
  <div className={styles.actions}>
130
- {primaryCta && <Button variant="primary" href={primaryCta.href}>{primaryCta.label}</Button>}
131
- {secondaryCta && <Button variant="secondary" href={secondaryCta.href}>{secondaryCta.label}</Button>}
134
+ {primaryCta && (
135
+ <Button
136
+ variant="primary"
137
+ tone={primaryCta.tone}
138
+ href={primaryCta.href}
139
+ >
140
+ {primaryCta.label}
141
+ </Button>
142
+ )}
143
+ {secondaryCta && (
144
+ <Button
145
+ variant="secondary"
146
+ tone={secondaryCta.tone}
147
+ href={secondaryCta.href}
148
+ >
149
+ {secondaryCta.label}
150
+ </Button>
151
+ )}
132
152
  {tertiaryCta && <Button variant="ghost" href={tertiaryCta.href}>{tertiaryCta.label} →</Button>}
133
153
  </div>
134
154
  )}
@@ -14,11 +14,17 @@
14
14
  * - ghost: plain text + arrow
15
15
  * - on-dark: primary variant inverted for use inside cobalt CTA panels
16
16
  *
17
+ * Tone (optional): override the primary/secondary fill colour. The
18
+ * brand default is cobalt; product pages with an orange-accent identity
19
+ * (e.g. mydash) pass `tone="orange"` to flip the primary CTA to
20
+ * KNVB-orange while keeping the rest of the brand chrome intact.
21
+ *
17
22
  * Usage:
18
23
  *
19
24
  * <Button href="/apps">Install</Button>
20
25
  * <Button variant="secondary" href="/partners">Get a demo</Button>
21
26
  * <Button variant="ghost" href="https://github.com/...">View on GitHub →</Button>
27
+ * <Button tone="orange" href="/install">Install from app store</Button>
22
28
  */
23
29
 
24
30
  import React from 'react';
@@ -26,6 +32,7 @@ import styles from './Button.module.css';
26
32
 
27
33
  export default function Button({
28
34
  variant = 'primary',
35
+ tone,
29
36
  href,
30
37
  size = 'md',
31
38
  className,
@@ -36,6 +43,7 @@ export default function Button({
36
43
  styles.btn,
37
44
  styles['v-' + variant],
38
45
  styles['s-' + size],
46
+ tone && styles['t-' + tone],
39
47
  className,
40
48
  ].filter(Boolean).join(' ');
41
49
 
@@ -86,3 +86,26 @@
86
86
 
87
87
  .v-on-dark-primary :global(.next-blue),
88
88
  .v-primary :global(.next-blue) { color: var(--c-nextcloud-cyan); }
89
+
90
+ /* Tone modifier for the primary variant. The brand default is the
91
+ cobalt fill above; product pages with an orange-accent identity
92
+ (mydash is the first) opt in by passing tone="orange". The brand
93
+ rule "one orange accent per screen" still applies; this just lets
94
+ the screen's single accent live on the primary CTA instead of
95
+ elsewhere. */
96
+ .t-orange.v-primary {
97
+ background: var(--c-orange-knvb);
98
+ border-color: var(--c-orange-knvb);
99
+ }
100
+ .t-orange.v-primary:hover {
101
+ background: var(--c-orange-knvb-700, #c8482f);
102
+ border-color: var(--c-orange-knvb-700, #c8482f);
103
+ }
104
+ .t-orange.v-secondary {
105
+ color: var(--c-orange-knvb);
106
+ border-color: var(--c-orange-knvb);
107
+ }
108
+ .t-orange.v-secondary:hover {
109
+ border-color: var(--c-orange-knvb-700, #c8482f);
110
+ color: var(--c-orange-knvb-700, #c8482f);
111
+ }
@@ -17,7 +17,24 @@
17
17
  * <DetailHero appId="openregister" ... /> // looks up downloads automatically
18
18
  */
19
19
 
20
- import data from '../../../data/app-downloads.json';
20
+ /* The JSON lives at design-system/data/app-downloads.json — three
21
+ levels up from this file inside the monorepo. When the preset is
22
+ installed via npm into a consumer site, that path resolves outside
23
+ the package directory and webpack 404s the import. Wrap in
24
+ require + try/catch so the consumer build falls back to an empty
25
+ stats payload instead of failing the whole build over a missing
26
+ download counter. */
27
+ let data;
28
+ try {
29
+ // eslint-disable-next-line global-require, import/no-unresolved
30
+ data = require('../../../data/app-downloads.json');
31
+ } catch (e) {
32
+ data = {
33
+ generated_at: null,
34
+ totals: { downloads: 0, apps_total: 0, apps_in_store: 0 },
35
+ apps: [],
36
+ };
37
+ }
21
38
 
22
39
  export default data;
23
40
 
package/src/index.js CHANGED
@@ -125,6 +125,9 @@ const baseFooter = () => ({
125
125
  * footer (per-property fallback: any of style/links/copyright the
126
126
  * site omits keeps its brand default — pass `footer: { links: [...] }`
127
127
  * to swap columns while inheriting the KvK/BTW copyright),
128
+ * minigames (default true; set false to drop the brand canal-footer's
129
+ * boat-sinking + kade-cyclist mini-games on product pages while
130
+ * keeping the static skyline + canal decoration),
128
131
  * customCss[] (appended to brand.css), plugins[], presets,
129
132
  * i18n (overrides defaults)
130
133
  */
@@ -212,6 +215,26 @@ function createConfig(opts) {
212
215
  copyright: f.copyright || baseFooterCopyright(),
213
216
  };
214
217
  })(),
218
+ /* The brand canal-footer carries two interactive mini-games
219
+ (boat-sinking + kade-cyclist). They're a kit-flavour win on
220
+ the design-system showcase but distract on product-page
221
+ landings. Surface as a top-level themeConfig flag so the
222
+ Footer swizzle can drop the game DOM + lazy scripts when a
223
+ site opts out, while still keeping the static skyline +
224
+ canal decoration. Default true preserves prior behaviour. */
225
+ minigames: opts.minigames !== false,
226
+ /* Footer brand block (the wordmark + tagline + triad + socials
227
+ on the left of the canal-footer grid).
228
+ undefined -> wordmark = 'Conduction' (product-page default;
229
+ previously this fell back to the site title,
230
+ which made every product-page footer wear the
231
+ product's wordmark instead of the company's).
232
+ { wordmark: 'X' } -> single custom brand
233
+ { brands: [{wordmark, logo, href}, ...] } -> dual-brand row,
234
+ rendered side by side. Used by product pages
235
+ built jointly with a partner (mydash + Sendent
236
+ is the first case). */
237
+ footerBrand: opts.footerBrand || null,
215
238
  },
216
239
  opts.themeConfig || {}
217
240
  ),
@@ -45,24 +45,44 @@ function FooterLink({label, href, to}) {
45
45
  }
46
46
 
47
47
  export default function Footer() {
48
- const {footer, navbar} = useThemeConfig();
48
+ const themeConfig = useThemeConfig();
49
+ const {footer, navbar} = themeConfig;
50
+ /* `minigames` and `footerBrand` are top-level themeConfig flags
51
+ surfaced through createConfig() opts. See createConfig() in
52
+ ../../index.js for the option semantics. */
53
+ const minigamesOn = themeConfig.minigames !== false;
54
+ const footerBrand = themeConfig.footerBrand || null;
55
+
49
56
  const location = useLocation();
50
57
  /* Brand switch follows the pathname: /connext or /commonground sections
51
58
  show their styled wordmark and slot themselves into the triad row.
52
- Outside sub-brand sections the wordmark falls back to the navbar
53
- title (the parent brand, typically "Conduction"). */
59
+ Outside sub-brand sections the wordmark defaults to "Conduction"
60
+ for the company anchor. Sites can override the default via
61
+ `themeConfig.footerBrand = { wordmark: '...' }`, or render a dual
62
+ brand row via `{ brands: [{wordmark, logo, href}, ...] }` for
63
+ product pages co-branded with a partner. The legacy fallback to
64
+ `navbar.title` was misleading on product-page footers (mydash
65
+ showing "MyDash" rather than "Conduction"); the company-anchor
66
+ reading wins. */
54
67
  const brand = brandFor(location.pathname, navbar?.title);
55
- const wordmark = brand ? brand.wordmark : (navbar?.title || 'Conduction');
68
+ const defaultWordmark = footerBrand?.wordmark || 'Conduction';
69
+ const wordmark = brand ? brand.wordmark : defaultWordmark;
70
+ const brandRow = !brand && Array.isArray(footerBrand?.brands) ? footerBrand.brands : null;
56
71
 
57
72
  /* canal-footer.js is loaded post-hydration so its DOM mutations
58
73
  (filling .skyline, animating boats) don't trip React hydration
59
- mismatches. See docs in utils/lazyScript.js. */
74
+ mismatches. See docs in utils/lazyScript.js. We always load
75
+ canal-footer.js even when minigames are off, because the same
76
+ script populates the static skyline; canal-footer.js is null-safe
77
+ against a missing game-hud, so the game wiring no-ops cleanly. */
60
78
  useLazyScript('/lib/canal-footer.js', 'canal-footer');
61
79
 
62
- /* kade-cyclist.js is the second hidden footer minigame. It listens for
63
- clicks on .ki-bike-1 / .ki-bike-2 inside the kade strip and runs a
64
- dodge-the-traffic round in parallel with the boat game. */
65
- useLazyScript('/lib/kade-cyclist.js', 'kade-cyclist');
80
+ /* kade-cyclist.js is the second hidden footer minigame. Unlike the
81
+ canal script it has no static side-effects, so when a product page
82
+ opts out of minigames we feed `useLazyScript` a falsy src to skip
83
+ the load. The hook still runs unconditionally — rules-of-hooks
84
+ stays compliant. */
85
+ useLazyScript(minigamesOn ? '/lib/kade-cyclist.js' : null, 'kade-cyclist');
66
86
 
67
87
  /* Re-hydrate the canal-footer + kade-cyclist runtimes on every mount,
68
88
  including SPA route changes that re-render this Footer component.
@@ -80,14 +100,17 @@ export default function Footer() {
80
100
  function tryHydrate() {
81
101
  if (cancelled) return;
82
102
  const canalReady = !!window.CanalFooter?.hydrate;
83
- const kadeReady = !!window.KadeCyclist?.hydrate;
103
+ /* When minigames are off, kade-cyclist.js was never loaded, so
104
+ polling for it would loop forever. Treat it as ready in that
105
+ case so the loop exits after canal-footer is wired. */
106
+ const kadeReady = !minigamesOn || !!window.KadeCyclist?.hydrate;
84
107
  if (canalReady) window.CanalFooter.hydrate();
85
- if (kadeReady) window.KadeCyclist.hydrate();
108
+ if (kadeReady && minigamesOn) window.KadeCyclist.hydrate();
86
109
  if (!canalReady || !kadeReady) requestAnimationFrame(tryHydrate);
87
110
  }
88
111
  tryHydrate();
89
112
  return () => { cancelled = true; };
90
- }, [isBrowser, location.pathname]);
113
+ }, [isBrowser, location.pathname, minigamesOn]);
91
114
 
92
115
  if (!footer) return null;
93
116
  const {links = [], copyright} = footer;
@@ -219,23 +242,30 @@ export default function Footer() {
219
242
  </svg>
220
243
  </div>
221
244
 
222
- {/* Mini-game HUD: timer + counter */}
223
- <div className="game-hud" aria-live="polite" aria-label="Boat-sinking mini game">
224
- <div className="hud-block hud-counter">
225
- <span className="hud-num" data-counter="">100</span>
226
- <span className="hud-label">Boats left</span>
227
- </div>
228
- <div className="hud-block hud-timer">
229
- <span className="hud-num" data-timer="">60</span>
230
- <span className="hud-label">Seconds</span>
231
- </div>
232
- </div>
245
+ {/* Mini-game HUD + game-over dialog + restart button. canal-footer.js
246
+ wires these via querySelector; the script's null-guards (added in
247
+ the same change) let it skip game initialisation cleanly when
248
+ they're absent on a product page. */}
249
+ {minigamesOn && (
250
+ <>
251
+ <div className="game-hud" aria-live="polite" aria-label="Boat-sinking mini game">
252
+ <div className="hud-block hud-counter">
253
+ <span className="hud-num" data-counter="">100</span>
254
+ <span className="hud-label">Boats left</span>
255
+ </div>
256
+ <div className="hud-block hud-timer">
257
+ <span className="hud-num" data-timer="">60</span>
258
+ <span className="hud-label">Seconds</span>
259
+ </div>
260
+ </div>
233
261
 
234
- <div className="game-over" role="dialog" aria-label="Mini game over">
235
- <p className="go-title" data-go-title="">Time's up</p>
236
- <p className="go-stat"><span data-go-sunk="">0</span> sunk</p>
237
- <button type="button" data-restart="">Play again</button>
238
- </div>
262
+ <div className="game-over" role="dialog" aria-label="Mini game over">
263
+ <p className="go-title" data-go-title="">Time's up</p>
264
+ <p className="go-stat"><span data-go-sunk="">0</span> sunk</p>
265
+ <button type="button" data-restart="">Play again</button>
266
+ </div>
267
+ </>
268
+ )}
239
269
 
240
270
  <svg className="canal-waves" viewBox="0 0 1400 500" preserveAspectRatio="none" aria-hidden="true">
241
271
  <path className="w1" d="M 0,96 L 140,82 L 280,96 L 420,82 L 560,96 L 700,82 L 840,96 L 980,82 L 1120,96 L 1260,82 L 1400,96"/>
@@ -245,7 +275,43 @@ export default function Footer() {
245
275
 
246
276
  <div className="footer-grid">
247
277
  <div className="brand">
248
- <div className="wm">{wordmark}</div>
278
+ {brandRow ? (
279
+ /* Dual-brand row: product pages co-built with a partner
280
+ render both wordmarks/logos side by side. The triad
281
+ below grows a partner segment in the same order. */
282
+ <div
283
+ className="wm-row"
284
+ style={{display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap'}}
285
+ >
286
+ {brandRow.map((b, i) => {
287
+ const inner = b.logo ? (
288
+ <img
289
+ src={b.logo}
290
+ alt={b.wordmark}
291
+ style={{height: 32, width: 'auto', display: 'block'}}
292
+ />
293
+ ) : (
294
+ <span className="wm">{b.wordmark}</span>
295
+ );
296
+ return b.href ? (
297
+ <a
298
+ key={i}
299
+ href={b.href}
300
+ target={b.href.startsWith('http') ? '_blank' : undefined}
301
+ rel={b.href.startsWith('http') ? 'noopener noreferrer' : undefined}
302
+ aria-label={b.wordmark}
303
+ style={{textDecoration: 'none'}}
304
+ >
305
+ {inner}
306
+ </a>
307
+ ) : (
308
+ <React.Fragment key={i}>{inner}</React.Fragment>
309
+ );
310
+ })}
311
+ </div>
312
+ ) : (
313
+ <div className="wm">{wordmark}</div>
314
+ )}
249
315
  <p>
250
316
  Open-source apps for <span className="next-blue">Nextcloud</span>. Built and
251
317
  maintained by Conduction in Amsterdam, released under EUPL-1.2.
@@ -253,7 +319,14 @@ export default function Footer() {
253
319
  <div className="triad">
254
320
  <span>
255
321
  <span className="h"></span>
256
- Conduction{brand && <> · {brand.label}</>} · <span className="next-blue">Nextcloud</span>
322
+ Conduction
323
+ {brand && <> · {brand.label}</>}
324
+ {brandRow && brandRow
325
+ .filter((b) => b.wordmark && b.wordmark !== 'Conduction')
326
+ .map((b, i) => (
327
+ <React.Fragment key={i}> · {b.wordmark}</React.Fragment>
328
+ ))}
329
+ {' '}· <span className="next-blue">Nextcloud</span>
257
330
  </span>
258
331
  </div>
259
332
  <div className="socials">
@@ -330,13 +403,20 @@ export default function Footer() {
330
403
 
331
404
  {/* Hidden templates cloned by canal-footer.js to populate the
332
405
  skyline (5 trapgevel variants) and to spawn boats during the
333
- mini-game (sailing ship, cargo, frigate, battleship boss). */}
334
- <FooterTemplates />
406
+ mini-game (sailing ship, cargo, frigate, battleship boss).
407
+ When minigames are off the boat templates aren't reachable
408
+ anyway (canal-footer.js's game wiring is null-guarded), but
409
+ dropping them shaves a few KB of inline SVG from the SSR
410
+ payload on product pages. */}
411
+ <FooterTemplates includeBoats={minigamesOn} />
335
412
 
336
413
  {/* Cross-game completion dialog. Listens for connext:gameend (HexRain,
337
414
  canal mini-game) and reports the result with replay + cross-game
338
- progress. Position: fixed via CSS module so it overlays the page. */}
339
- <GameModal />
415
+ progress. Position: fixed via CSS module so it overlays the page.
416
+ Only mounted when minigames are on so a product page doesn't
417
+ carry the dialog DOM + listeners for an interaction it can't
418
+ trigger. */}
419
+ {minigamesOn && <GameModal />}
340
420
  </>
341
421
  );
342
422
  }
@@ -347,17 +427,22 @@ export default function Footer() {
347
427
  via .content. Because JSX puts children directly under the template
348
428
  instead of in its .content DocumentFragment, we inject the templates
349
429
  as raw HTML so the browser's HTML parser slots them correctly. */
350
- function FooterTemplates() {
430
+ function FooterTemplates({includeBoats = true}) {
431
+ const html = HOUSE_TEMPLATES_HTML + (includeBoats ? BOAT_TEMPLATES_HTML : '');
351
432
  return (
352
433
  <div
353
434
  style={{display: 'none'}}
354
435
  aria-hidden="true"
355
- dangerouslySetInnerHTML={{__html: TEMPLATES_HTML}}
436
+ dangerouslySetInnerHTML={{__html: html}}
356
437
  />
357
438
  );
358
439
  }
359
440
 
360
- const TEMPLATES_HTML = `
441
+ /* Skyline templates (5 trapgevel variants). canal-footer.js queries
442
+ #tpl-h-a … #tpl-h-e to clone them across the skyline width. Always
443
+ shipped — the static skyline is part of the brand decoration even
444
+ on product pages that opt out of minigames. */
445
+ const HOUSE_TEMPLATES_HTML = `
361
446
  <template id="tpl-h-a">
362
447
  <svg class="house h-a" viewBox="0 -2 80 202" xmlns="http://www.w3.org/2000/svg">
363
448
  <path d="M 0,200 L 0,38 L 8,38 L 8,26 L 16,26 L 16,14 L 24,14 L 24,8 L 56,8 L 56,14 L 64,14 L 64,26 L 72,26 L 72,38 L 80,38 L 80,200 Z" fill="var(--c-orange-knvb)"/>
@@ -426,7 +511,14 @@ const TEMPLATES_HTML = `
426
511
  <rect x="39.5" y="175" width="16" height="34" fill="rgba(11,32,73,0.55)"/>
427
512
  </svg>
428
513
  </template>
514
+ `;
429
515
 
516
+ /* Boat templates (sailing ship, cargo, frigate, battleship boss).
517
+ canal-footer.js spawns these as the boat-sinking mini-game escalates;
518
+ when a site opts out of minigames the script's null-guard skips
519
+ spawning and these templates are unreachable, so they're elided
520
+ from the SSR payload. */
521
+ const BOAT_TEMPLATES_HTML = `
430
522
  <template id="tpl-ship-sailing">
431
523
  <svg class="ci ci-sailing" width="100" height="60" viewBox="-4 -42 108 60" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
432
524
  <path d="M 0,12 L 6,16 L 90,16 L 96,12 L 96,8 L 0,8 Z" fill="var(--c-orange-knvb)"/>
@@ -23,10 +23,18 @@
23
23
  import {useEffect} from 'react';
24
24
  import useIsBrowser from '@docusaurus/useIsBrowser';
25
25
 
26
+ /**
27
+ * Pass a falsy `src` to skip the load entirely. The hook still runs
28
+ * unconditionally so call sites stay rules-of-hooks-compliant: the
29
+ * Footer swizzle uses this to gate /lib/kade-cyclist.js behind the
30
+ * `minigames` themeConfig flag without splitting into conditional
31
+ * hook calls.
32
+ */
26
33
  export function useLazyScript(src, key) {
27
34
  const isBrowser = useIsBrowser();
28
35
  useEffect(() => {
29
36
  if (!isBrowser) return;
37
+ if (!src) return;
30
38
  if (document.querySelector(`script[data-lazy-script="${key}"]`)) return;
31
39
  const script = document.createElement('script');
32
40
  script.src = src;
@@ -126,6 +126,21 @@
126
126
  const goSunk = root.querySelector('[data-go-sunk]');
127
127
  const goRestart = root.querySelector('[data-restart]');
128
128
 
129
+ /* Product pages opt out of the boat-sinking mini-game by setting
130
+ `themeConfig.minigames = false` in createConfig(); the Footer
131
+ swizzle then doesn't render the .game-hud / .game-over / boat
132
+ templates. The skyline + house listeners + drifting fleet (built
133
+ above this point) are still wanted. Bail out before any game
134
+ wiring rather than null-deref'ing on the missing elements. */
135
+ if (!hud || !goRestart || !goPanel) {
136
+ /* Mark hydrated and expose a no-op API so the Footer's
137
+ useEffect re-hydrate loop exits cleanly on SPA route changes. */
138
+ window.CanalFooter = window.CanalFooter || {};
139
+ window.CanalFooter.hydrate = window.CanalFooter.hydrate || function () {};
140
+ window.CanalFooter._cleanup = window.CanalFooter._cleanup || function () {};
141
+ return;
142
+ }
143
+
129
144
  let game;
130
145
 
131
146
  function newGameState() {