@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
@@ -65,6 +65,7 @@ Most consume one or more primitives.
65
65
  - **BuildMock** — `BuildMock/BuildMock.jsx`. Looping "make it yours" animation: the orange Buildiq edit FAB is pressed, a component palette opens, and a widget is dragged into the page layout where it snaps in (~8s loop). `prefers-reduced-motion` / `running={false}` renders the static end state (component placed).
66
66
  - **KanbanMock** — `KanbanMock/KanbanMock.jsx`. Looping kanban drag for the pipelinq hero: a cursor lifts the top lead card, drags it to the next column, drops it flush into the column's card slot, and at the drop the column counts nudge (3→2, 2→3) and the per-lane € KPI tiles above the board shift the moved deal's value (~7s loop, clean reset). `prefers-reduced-motion` / `running={false}` renders the static board with the initial counts and totals.
67
67
  - **MockScene** — `MockScene/MockScene.jsx`. Free-form positioning stage for overlap shots of WidgetMocks + SidebarMocks (caller supplies canvas size and per-item x/y/z). The scene assembles on a loop: items land on a 220ms stagger, transform-only so the inline left/top positioning is never fought; `running={false}` / `prefers-reduced-motion` show the assembled scene.
68
+ - **ThemeSeamMock** — `ThemeSeamMock/ThemeSeamMock.jsx`. One AppMock frame rendered twice, the second inside a wrapper that redefines the palette and radius tokens. A stitched seam travels across the frame and the finish changes behind it (`--ts-dur`, 9s), then the loop hands the frame back. The themed layer is nothing but tokens, which is the claim Thematiq makes about a token set; the inner mocks run `running={false}` so their own loops do not compete with the seam. Themes: `lasuite`. `running={false}` / `prefers-reduced-motion` render the themed end state with the seam at rest.
68
69
  - **ExternalAppShelf** — `ExternalAppShelf/ExternalAppShelf.jsx`. Third-party-app shelf (Outlook-style, Mattermost, Keycloak, etc.) used on `/connext`. Specimen: `preview/components/external-app-shelf.html`.
69
70
  - **WidgetShelf** — `WidgetShelf/WidgetShelf.jsx`. Widget showcase with eyebrow + title + lede. Default is a slow single-row carousel (seamless duplicated track, pause on hover/focus, static wrapping grid under `prefers-reduced-motion`); loop duration derives from the widget count (6s per card, min 30s; `speed` overrides), with pause/play + prev/next controls (cobalt ghost, hidden under reduced motion). Widgets without an explicit `panel` get a deterministic auto-generated mini panel (KPI tile / bar chart / list rows / donut, hashed from the title). `carousel={false}` renders the N-column grid. Specimen: `preview/components/widget-shelf.html`.
70
71
  - **Showcase** — `Showcase/Showcase.jsx`. Multi-item collapsible showcase with screenshot + body. Used on `/commonground` for the "5-lagen model". Specimen: `preview/components/showcase.html`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.37.0",
3
+ "version": "3.39.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js",
6
6
  "test": "node --test"
@@ -0,0 +1,57 @@
1
+ /**
2
+ * no-icu-messages.test.js — no translatable string may use ICU plural
3
+ * or select syntax.
4
+ *
5
+ * Docusaurus's `translate()` and `<Translate>` substitute
6
+ * `{placeholder}` and nothing else. An ICU message therefore does not
7
+ * fail, does not warn, and does not fall back: it renders to the
8
+ * reader verbatim, braces and keywords and all, in every locale. It
9
+ * shipped that way in the game-over modal and was reported from the
10
+ * live site.
11
+ *
12
+ * The fix is two messages chosen in JS. This test is the reason nobody
13
+ * has to remember that.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const test = require('node:test');
19
+ const assert = require('node:assert/strict');
20
+ const path = require('node:path');
21
+ const fs = require('node:fs');
22
+
23
+ const SRC = path.resolve(__dirname, '..');
24
+ /* `{name, plural, ...}` / `{count, select, ...}`, the two ICU forms
25
+ that render raw. Deliberately loose: any argument name, any spacing. */
26
+ const ICU = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\s*,/;
27
+
28
+ function sourceFiles(dir) {
29
+ const out = [];
30
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
31
+ const full = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
34
+ out.push(...sourceFiles(full));
35
+ } else if (/\.(jsx?|tsx?)$/.test(entry.name)) {
36
+ out.push(full);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ test('no component ships an ICU plural or select message', () => {
43
+ const offenders = [];
44
+ for (const file of sourceFiles(SRC)) {
45
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
46
+ lines.forEach((line, i) => {
47
+ /* Skip the comment that explains the rule. */
48
+ if (/^\s*(\*|\/\/|\/\*)/.test(line)) return;
49
+ if (ICU.test(line)) offenders.push(`${path.relative(SRC, file)}:${i + 1}: ${line.trim()}`);
50
+ });
51
+ }
52
+ assert.deepEqual(
53
+ offenders,
54
+ [],
55
+ 'ICU syntax renders verbatim to the reader; use two messages picked in JS:\n' + offenders.join('\n'),
56
+ );
57
+ });
@@ -32,28 +32,39 @@
32
32
  */
33
33
 
34
34
  import React from 'react';
35
- import {useColorMode} from '@docusaurus/theme-common';
36
35
  import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
37
36
  import useBaseUrl from '@docusaurus/useBaseUrl';
38
37
  import styles from './AiDisclosure.module.css';
39
- import {isValidKind, getCopy, getViewBox, getIconPath, AI_KINDS} from './disclosure';
38
+ import {isValidKind, getCopy, getLinkText, resolveLinkHref, LINK_HREF, AI_KINDS} from './disclosure';
39
+
40
+ // The Commission's own AI letterforms, lifted verbatim from the
41
+ // vendored Basic mark (static/img/ai-disclosure/ai-black.svg, paths 2
42
+ // and 3) so the glyph a reader recognises is unchanged. What is
43
+ // replaced is only the container: the Commission's black disc becomes
44
+ // Conduction's hexagon in neutral grey, matching the hex used for app
45
+ // glyphs across the design system.
46
+ //
47
+ // Both live in the mark's original 566.93 viewBox, so the letterforms
48
+ // keep their exact position and proportion inside the new container.
49
+ // The hexagon is regular, centred on the disc's own centre
50
+ // (272.03, 283.46) with a circumradius of 200 - larger than the disc's
51
+ // 182.74 because a hexagon's inradius is only 0.866 of its
52
+ // circumradius, and the letters need the vertical room.
53
+ const HEX_PATH =
54
+ 'M472.03,283.46 L372.03,456.67 L172.03,456.67 L72.03,283.46 L172.03,110.25 L372.03,110.25 Z';
55
+ const AI_LETTER_A =
56
+ 'M170.79,353.74c-1.08,0-2.05-.43-2.92-1.31-.88-.87-1.31-1.84-1.31-2.92,0-.67.07-1.27.2-1.81l47.34-129.32c.4-1.48,1.24-2.79,2.52-3.93,1.27-1.14,3.05-1.71,5.34-1.71h29.81c2.28,0,4.06.57,5.34,1.71,1.27,1.14,2.11,2.45,2.52,3.93l47.14,129.32c.27.54.4,1.14.4,1.81,0,1.08-.44,2.05-1.31,2.92s-1.91,1.31-3.12,1.31h-24.78c-2.01,0-3.52-.5-4.53-1.51-1.01-1.01-1.65-1.91-1.91-2.72l-7.86-20.55h-53.78l-7.65,20.55c-.27.81-.88,1.71-1.81,2.72-.94,1.01-2.55,1.51-4.83,1.51h-24.78ZM218.13,299.96h37.47l-18.93-53.18-18.53,53.18Z';
57
+ const AI_LETTER_I =
58
+ 'M328.11,353.74c-1.48,0-2.69-.47-3.63-1.41-.94-.94-1.41-2.15-1.41-3.63v-130.93c0-1.48.47-2.68,1.41-3.63s2.15-1.41,3.63-1.41h26.99c1.48,0,2.68.47,3.63,1.41.94.94,1.41,2.15,1.41,3.63v130.93c0,1.48-.47,2.69-1.41,3.63-.94.94-2.15,1.41-3.63,1.41h-26.99Z';
40
59
 
41
60
  export default function AiDisclosure({kind, className}) {
42
- const {colorMode} = useColorMode();
43
- const {i18n} = useDocusaurusContext();
61
+ const {i18n, siteConfig} = useDocusaurusContext();
44
62
  const locale = (i18n && i18n.currentLocale) || 'en';
45
63
 
46
64
  const isKindValid = isValidKind(kind);
47
- // Dark-ink mark on light surfaces, light-ink mark on dark surfaces -
48
- // the opaque treatments (not the transparent ones) so the mark
49
- // reads correctly regardless of what sits behind the banner panel.
50
- const treatment = colorMode === 'dark' ? 'white' : 'black';
51
- // Served as a plain static file (static/img/ai-disclosure/), not a
52
- // webpack `.svg` import - see disclosure.js's ICONS_BASE_PATH
53
- // comment for why. useBaseUrl() must run unconditionally (React
54
- // hooks rule), so it's called even when kind is invalid; the result
55
- // is simply unused in that branch.
56
- const iconPath = useBaseUrl(getIconPath(isKindValid ? kind : 'assisted', treatment));
65
+ // useBaseUrl() must run unconditionally (React hooks rule), so it is
66
+ // called even when kind is invalid; the result is unused in that branch.
67
+ const href = resolveLinkHref(siteConfig && siteConfig.url, useBaseUrl(LINK_HREF));
57
68
 
58
69
  if (!isKindValid) {
59
70
  if (typeof console !== 'undefined') {
@@ -65,18 +76,31 @@ export default function AiDisclosure({kind, className}) {
65
76
  return null;
66
77
  }
67
78
 
68
- const {width, height} = getViewBox(kind);
69
79
  const copy = getCopy(kind, locale);
80
+ const linkText = getLinkText(locale);
70
81
 
71
82
  return (
72
83
  <div className={[styles.disclosure, className].filter(Boolean).join(' ')} role="note">
73
- <img
74
- src={iconPath}
75
- alt=""
84
+ {/* One mark for all three kinds and both colour modes: the kind is
85
+ stated in the copy, and grey-on-white reads the same as
86
+ grey-on-dark, so there is no treatment to switch. */}
87
+ <svg
76
88
  className={styles.mark}
77
- style={{aspectRatio: `${width} / ${height}`}}
78
- />
79
- <p className={styles.copy}>{copy}</p>
89
+ viewBox="0 0 566.93 566.93"
90
+ role="img"
91
+ aria-label="AI"
92
+ focusable="false"
93
+ xmlns="http://www.w3.org/2000/svg">
94
+ <path className={styles.markHex} d={HEX_PATH} />
95
+ <path className={styles.markGlyph} fillRule="evenodd" d={AI_LETTER_A} />
96
+ <path className={styles.markGlyph} d={AI_LETTER_I} />
97
+ </svg>
98
+ <p className={styles.copy}>
99
+ {copy}{' '}
100
+ <a className={styles.link} href={href}>
101
+ {linkText}
102
+ </a>
103
+ </p>
80
104
  </div>
81
105
  );
82
106
  }
@@ -19,17 +19,52 @@
19
19
  border-radius: var(--radius-md);
20
20
  }
21
21
 
22
+ /**
23
+ * The mark is a single square hexagon glyph now, not the three
24
+ * differently-proportioned Commission word-marks, so it gets a fixed
25
+ * square box rather than a per-kind aspect-ratio.
26
+ */
22
27
  .mark {
23
28
  display: block;
24
- width: 96px;
25
- max-width: 30%;
26
- height: auto;
29
+ width: 40px;
30
+ height: 40px;
27
31
  flex-shrink: 0;
28
32
  }
29
33
 
34
+ /**
35
+ * Neutral grey, and deliberately not brand orange: this is a factual
36
+ * disclosure, not a call to action. Grey reads correctly on both the
37
+ * light and the dark banner surface, which is why the mark needs no
38
+ * light/dark treatment switch at all.
39
+ */
40
+ .markHex {
41
+ fill: var(--ai-mark-hex, #6b7280);
42
+ }
43
+
44
+ .markGlyph {
45
+ fill: var(--ai-mark-glyph, #ffffff);
46
+ }
47
+
48
+ [data-theme='dark'] .markHex {
49
+ fill: var(--ai-mark-hex-dark, #9ca3af);
50
+ }
51
+
52
+ [data-theme='dark'] .markGlyph {
53
+ fill: var(--ai-mark-glyph-dark, #1f2937);
54
+ }
55
+
30
56
  .copy {
31
57
  margin: 0;
32
58
  font-size: 14px;
33
59
  line-height: 1.5;
34
60
  color: var(--c-cobalt-700);
35
61
  }
62
+
63
+ .link {
64
+ color: inherit;
65
+ text-decoration: underline;
66
+ }
67
+
68
+ .link:hover {
69
+ text-decoration: none;
70
+ }
@@ -11,9 +11,9 @@
11
11
  * `@docusaurus/useBaseUrl`) are stubbed via esbuild plugins since this
12
12
  * test runs outside an actual Docusaurus build; the useBaseUrl stub
13
13
  * prefixes a fake `/base/` the same way the real hook prefixes a
14
- * site's configured baseUrl, so the assertion is "the resolved icon
15
- * path for this kind/treatment was passed through base-url
16
- * resolution", not a snapshot of a real deployed URL.
14
+ * site's configured baseUrl, so the assertion is "the policy link was
15
+ * passed through base-url resolution", not a snapshot of a real
16
+ * deployed URL.
17
17
  *
18
18
  * @spec openspec/changes/ai-content-disclosure/tasks.md#task-5.2
19
19
  */
@@ -42,7 +42,7 @@ const cssModuleStub = {
42
42
  },
43
43
  };
44
44
 
45
- function docusaurusStub({colorMode, locale}) {
45
+ function docusaurusStub({colorMode, locale, siteUrl}) {
46
46
  return {
47
47
  name: 'docusaurus-stub',
48
48
  setup(b) {
@@ -54,7 +54,7 @@ function docusaurusStub({colorMode, locale}) {
54
54
  loader: 'js',
55
55
  }));
56
56
  b.onLoad({filter: /^docusaurus-context$/, namespace: 'docusaurus-stub'}, () => ({
57
- contents: `export default function useDocusaurusContext() { return {i18n: {currentLocale: ${JSON.stringify(locale)}}}; }`,
57
+ contents: `export default function useDocusaurusContext() { return {i18n: {currentLocale: ${JSON.stringify(locale)}}, siteConfig: {url: ${JSON.stringify(siteUrl)}}}; }`,
58
58
  loader: 'js',
59
59
  }));
60
60
  // Mirrors the real hook's job: prefix the site's baseUrl onto a
@@ -73,7 +73,7 @@ function docusaurusStub({colorMode, locale}) {
73
73
  // inside the package instead, mirroring build-kit.mjs's `.tmp-build-kit`.
74
74
  const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
75
75
 
76
- async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} = {}) {
76
+ async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en', siteUrl = 'https://conduction.nl'} = {}) {
77
77
  const scratchRoot = path.join(PRESET_ROOT, '.tmp-ai-disclosure-test');
78
78
  await fs.mkdir(scratchRoot, {recursive: true});
79
79
  const tmpDir = await fs.mkdtemp(path.join(scratchRoot, 'run-'));
@@ -89,7 +89,7 @@ async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} =
89
89
  tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
90
90
  platform: 'node',
91
91
  external: ['react'],
92
- plugins: [cssModuleStub, docusaurusStub({colorMode, locale})],
92
+ plugins: [cssModuleStub, docusaurusStub({colorMode, locale, siteUrl})],
93
93
  logLevel: 'warning',
94
94
  });
95
95
  delete require.cache[require.resolve(outFile)];
@@ -101,20 +101,32 @@ async function renderAiDisclosure(props, {colorMode = 'light', locale = 'en'} =
101
101
  }
102
102
  }
103
103
 
104
- test('renders the generated mark + English copy in light mode', async () => {
104
+ test('renders the hexagon mark, English copy and the base-url policy link', async () => {
105
105
  const html = await renderAiDisclosure({kind: 'generated'}, {colorMode: 'light', locale: 'en'});
106
- assert.match(html, /\/base\/img\/ai-disclosure\/ai-generated-black\.svg/);
106
+ assert.match(html, /<svg[^>]*aria-label="AI"/);
107
107
  assert.match(html, /This page was generated with AI\./);
108
+ assert.match(html, /href="\/base\/\/ai"/);
109
+ assert.match(html, /how Conduction uses AI\./);
108
110
  });
109
111
 
110
- test('renders the white (light-ink-on-dark) treatment in dark mode', async () => {
111
- const html = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'dark', locale: 'en'});
112
- assert.match(html, /\/base\/img\/ai-disclosure\/ai-modified-white\.svg/);
112
+ test('renders the same mark in dark mode, with no treatment switch', async () => {
113
+ const light = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'light', locale: 'en'});
114
+ const dark = await renderAiDisclosure({kind: 'modified'}, {colorMode: 'dark', locale: 'en'});
115
+ assert.equal(dark, light);
116
+ assert.doesNotMatch(dark, /<img/);
113
117
  });
114
118
 
115
119
  test('renders Dutch copy when the active locale is nl', async () => {
116
120
  const html = await renderAiDisclosure({kind: 'assisted'}, {colorMode: 'light', locale: 'nl'});
117
- assert.match(html, /Deze pagina is geschreven met hulp van AI\./);
121
+ assert.match(html, /Deze pagina is geschreven met hulp van AI, bijvoorbeeld voor spelling en onderzoek\./);
122
+ });
123
+
124
+ test('points at the policy page on conduction.nl from a product docs site', async () => {
125
+ const html = await renderAiDisclosure(
126
+ {kind: 'assisted'},
127
+ {colorMode: 'light', locale: 'en', siteUrl: 'https://dossiq.conduction.nl'},
128
+ );
129
+ assert.match(html, /href="https:\/\/conduction\.nl\/ai"/);
118
130
  });
119
131
 
120
132
  test('renders nothing for an unrecognised kind', async () => {
@@ -32,6 +32,11 @@ const {
32
32
  getIconPath,
33
33
  getViewBox,
34
34
  getCopy,
35
+ getLinkText,
36
+ LINK_TEXT,
37
+ LINK_HREF,
38
+ POLICY_URL,
39
+ resolveLinkHref,
35
40
  } = require('../disclosure');
36
41
 
37
42
  // Icons are vendored as plain static files (not a webpack `.svg`
@@ -216,3 +221,58 @@ test('isValidKind rejects anything outside the three permitted values', () => {
216
221
  assert.equal(isValidKind(undefined), false);
217
222
  assert.equal(isValidKind(null), false);
218
223
  });
224
+
225
+ // -- "read more" pointer to the AI policy page --------------------------------
226
+ //
227
+ // The link is navigation, not a statement about the page, which is why
228
+ // it lives outside COPY and is asserted separately here.
229
+
230
+ test('link text covers every locale COPY covers', () => {
231
+ for (const locale of LOCALES) {
232
+ assert.equal(
233
+ typeof LINK_TEXT[locale],
234
+ 'string',
235
+ `LINK_TEXT is missing locale "${locale}" that COPY defines`,
236
+ );
237
+ assert.ok(LINK_TEXT[locale].length > 0, `LINK_TEXT["${locale}"] is empty`);
238
+ }
239
+ });
240
+
241
+ test('getLinkText falls back to English for an unknown locale', () => {
242
+ assert.equal(getLinkText('en'), LINK_TEXT.en);
243
+ assert.equal(getLinkText('nl'), LINK_TEXT.nl);
244
+ assert.equal(getLinkText('xx'), LINK_TEXT.en);
245
+ assert.equal(getLinkText(undefined), LINK_TEXT.en);
246
+ });
247
+
248
+ test('the link points at the AI policy page, site-relative', () => {
249
+ // Site-relative so useBaseUrl() can prefix a deployment baseUrl.
250
+ assert.equal(LINK_HREF, '/ai');
251
+ });
252
+
253
+ test('link text makes no compliance claim either', () => {
254
+ // Same denylist discipline as COPY: the pointer must not smuggle in a
255
+ // claim the copy is forbidden from making.
256
+ const denied = [/compliant/i, /compliance/i, /code of practice/i, /conform/i, /certified/i];
257
+ for (const locale of LOCALES) {
258
+ for (const pattern of denied) {
259
+ assert.ok(
260
+ !pattern.test(LINK_TEXT[locale]),
261
+ `LINK_TEXT["${locale}"] matches forbidden pattern ${pattern}`,
262
+ );
263
+ }
264
+ }
265
+ });
266
+
267
+ test('the link stays site-relative on conduction.nl itself', () => {
268
+ assert.equal(resolveLinkHref('https://conduction.nl', '/en/ai'), '/en/ai');
269
+ assert.equal(resolveLinkHref('https://www.conduction.nl/', '/ai'), '/ai');
270
+ });
271
+
272
+ test('off conduction.nl the link goes to the policy page there, not a local 404', () => {
273
+ assert.equal(POLICY_URL, 'https://conduction.nl/ai');
274
+ assert.equal(resolveLinkHref('https://dossiq.conduction.nl', '/ai'), POLICY_URL);
275
+ assert.equal(resolveLinkHref('https://docs.example.org', '/docs/ai'), POLICY_URL);
276
+ assert.equal(resolveLinkHref(undefined, '/ai'), POLICY_URL);
277
+ assert.equal(resolveLinkHref('not a url', '/ai'), POLICY_URL);
278
+ });
@@ -49,27 +49,78 @@ const COPY = {
49
49
  nl: {
50
50
  generated: 'Deze pagina is gegenereerd met AI.',
51
51
  modified: 'Deze pagina is gedeeltelijk aangepast met AI.',
52
- assisted: 'Deze pagina is geschreven met hulp van AI.',
52
+ assisted: 'Deze pagina is geschreven met hulp van AI, bijvoorbeeld voor spelling en onderzoek.',
53
53
  },
54
54
  en: {
55
55
  generated: 'This page was generated with AI.',
56
56
  modified: 'This page was partially modified with AI.',
57
- assisted: 'This page was written with AI assistance.',
57
+ assisted: 'This page was written using AI assistance, for example for spelling and research.',
58
58
  },
59
59
  de: {
60
60
  generated: 'Diese Seite wurde mit KI erstellt.',
61
61
  modified: 'Diese Seite wurde teilweise mit KI bearbeitet.',
62
- assisted: 'Diese Seite wurde mit KI-Unterstützung geschrieben.',
62
+ assisted:
63
+ 'Diese Seite wurde mit KI-Unterstützung geschrieben, zum Beispiel für Rechtschreibung und Recherche.',
63
64
  },
64
65
  fr: {
65
66
  generated: "Cette page a été générée avec l'IA.",
66
67
  modified: "Cette page a été partiellement modifiée avec l'IA.",
67
- assisted: "Cette page a été rédigée avec l'aide de l'IA.",
68
+ assisted:
69
+ "Cette page a été rédigée avec l'aide de l'IA, par exemple pour l'orthographe et la recherche.",
68
70
  },
69
71
  };
70
72
 
71
73
  const LOCALES = Object.keys(COPY);
72
74
 
75
+ // The "what does that mean" pointer. Kept OUT of the COPY table on
76
+ // purpose: COPY holds only statements of fact about the page (the
77
+ // denylist test asserts every value makes no compliance claim), while
78
+ // this is navigation. One link text per locale, identical across all
79
+ // three kinds - what the reader wants to know ("how does Conduction
80
+ // use AI?") does not change with the kind.
81
+ const LINK_TEXT = {
82
+ nl: 'Lees wat dat betekent en hoe Conduction AI gebruikt.',
83
+ en: 'Read what that means and how Conduction uses AI.',
84
+ de: 'Lesen Sie, was das bedeutet und wie Conduction KI einsetzt.',
85
+ fr: "Découvrez ce que cela signifie et comment Conduction utilise l'IA.",
86
+ };
87
+
88
+ // Site-relative. The page exists at src/pages/ai.mdx ("How we use AI")
89
+ // on conduction.nl only. Every product docs site uses this preset too,
90
+ // and none of them carries an /ai page, so off conduction.nl the link
91
+ // goes to the policy page on conduction.nl instead of a 404.
92
+ const LINK_HREF = '/ai';
93
+ const POLICY_SITE_HOSTS = ['conduction.nl', 'www.conduction.nl'];
94
+ const POLICY_URL = 'https://conduction.nl/ai';
95
+
96
+ /**
97
+ * Where the "read more" link points on a given site.
98
+ *
99
+ * @param {string} siteUrl the site's configured `url` (siteConfig.url)
100
+ * @param {string} localHref LINK_HREF already passed through useBaseUrl()
101
+ * @returns {string}
102
+ */
103
+ function resolveLinkHref(siteUrl, localHref) {
104
+ let host = '';
105
+ try {
106
+ host = new URL(siteUrl).hostname;
107
+ } catch (e) {
108
+ host = '';
109
+ }
110
+ return POLICY_SITE_HOSTS.indexOf(host) !== -1 ? localHref : POLICY_URL;
111
+ }
112
+
113
+ /**
114
+ * Localised "read more" text for the disclosure banner.
115
+ * Falls back to English for an unknown locale, same as getCopy().
116
+ *
117
+ * @param {string} locale
118
+ * @returns {string}
119
+ */
120
+ function getLinkText(locale) {
121
+ return LINK_TEXT[locale] || LINK_TEXT.en;
122
+ }
123
+
73
124
  function isValidKind(value) {
74
125
  return AI_KINDS.indexOf(value) !== -1;
75
126
  }
@@ -159,6 +210,9 @@ module.exports = {
159
210
  KIND_TO_FILE_PREFIX,
160
211
  VIEWBOX,
161
212
  COPY,
213
+ LINK_TEXT,
214
+ LINK_HREF,
215
+ POLICY_URL,
162
216
  ICONS_BASE_PATH,
163
217
  isValidKind,
164
218
  resolveAiFrontmatter,
@@ -166,4 +220,6 @@ module.exports = {
166
220
  getIconPath,
167
221
  getViewBox,
168
222
  getCopy,
223
+ getLinkText,
224
+ resolveLinkHref,
169
225
  };