@conduction/docusaurus-preset 3.31.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.
@@ -47,7 +47,7 @@ Most consume one or more primitives.
47
47
  - **FeatureGrid / FeatureGridItem / FeatureGridGroup** — `FeatureGrid/FeatureGrid.jsx`. Compact spec list with hover-tooltip and stable/beta/soon status hexes.
48
48
  - **HowSteps / HowStep** — `HowSteps/HowSteps.jsx`. Numbered process-step row. Auto-numbers and auto-tints (cobalt → orange → cobalt-700) when `number` and `tier` are omitted.
49
49
  - **Pipeline / PipelineStep / IconList** — `Pipeline/Pipeline.jsx`. Horizontal hex-numbered pipeline with chevron flow lines and optional source/consumer end-boxes.
50
- - **DetailHero** — `DetailHero/DetailHero.jsx`. Shared 1fr/360px hero pattern between app/solution/partner detail pages. Crumb + status + version + locales + downloads badge + h1 + tagline + 3 CTAs + right-column illustration.
50
+ - **DetailHero** — `DetailHero/DetailHero.jsx`. Shared 1fr/360px hero pattern between app/solution/partner detail pages. Crumb + status + version + locales + downloads badge + "View on GitHub" chip (both link to the repo via `repoHref` / GitHub tertiaryCta / ConductionNL fallback) + h1 + tagline + primary/secondary CTA pair + right-column illustration.
51
51
  - **FacetedFilters / FilterChip** — `FacetedFilters/FacetedFilters.jsx`. Sidebar facet checkbox panel + active-chip strip.
52
52
  - **ConductionBg** — `ConductionBg/ConductionBg.jsx`. Parallax honeycomb background. Real component with self-contained runtime hook.
53
53
  - **HexNetwork** — `HexNetwork/HexNetwork.jsx`. Centre hex with surrounding satellites. Used on `/support` to render the partner network around Conduction.
@@ -58,8 +58,12 @@ Most consume one or more primitives.
58
58
  - **ComposeBlock** — `ComposeBlock/ComposeBlock.jsx`. Branded code block (cobalt-900 + Plex Mono + filename pill + copy button). For docker-compose, bash recipes, and any verbatim copy-paste content. Used on `/demo`.
59
59
  - **AgentTrace** — `AgentTrace/AgentTrace.jsx`. Terminal-styled agent execution trace with cursor + mode chrome. Specimen: `preview/components/agent-trace.html`.
60
60
  - **AppMock** — `AppMock/AppMock.jsx`. Selector that renders one of 16 app-specific UI mockups (DeciDesk, DocuDesk, LarpingApp, LaunchPad + variants, NLDesign, OpenCatalogi, OpenConnector, OpenRegister, OpenWoo, PipelinQ, Procest, SoftwareCatalog, ZaakAfhandelApp). Each variant lives under `AppMock/variants/`. Specimen: `preview/components/app-mock.html`.
61
+ - **FlowMock** — `FlowMock/FlowMock.jsx`. Token-painted abstract of the flow editor: node cards on a dotted canvas with coloured left bars (mint trigger, lavender steps, vermillion end), curved grey edges with pill labels, and a mint run-line looping trigger → step → approval → end. Freezes to the full static route under `prefers-reduced-motion` or `running={false}`. Specimen: `preview/components/flow-mock.html`.
62
+ - **LeafMock** — `LeafMock/LeafMock.jsx`. Small integration-leaf panels for Showcase items: `leaf="contacts"` (hex-avatar rows + mint sync arrow to a phone), `"calendar"` (mini month grid + linked-event chip), `"mail"` (message list + mint create-from-email chip), `"files"` (folder/file rows + forest link-to-record chip).
63
+ - **BuildMock** — `BuildMock/BuildMock.jsx`. Looping "make it yours" animation: the orange OpenBuild 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).
64
+ - **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, and the column counts nudge (~7s loop). `prefers-reduced-motion` / `running={false}` renders the static board.
61
65
  - **ExternalAppShelf** — `ExternalAppShelf/ExternalAppShelf.jsx`. Third-party-app shelf (Outlook-style, Mattermost, Keycloak, etc.) used on `/connext`. Specimen: `preview/components/external-app-shelf.html`.
62
- - **WidgetShelf** — `WidgetShelf/WidgetShelf.jsx`. Widget showcase grid with eyebrow + title + lede + N-column widget cards. Specimen: `preview/components/widget-shelf.html`.
66
+ - **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`); `carousel={false}` renders the N-column grid. Specimen: `preview/components/widget-shelf.html`.
63
67
  - **Showcase** — `Showcase/Showcase.jsx`. Multi-item collapsible showcase with screenshot + body. Used on `/commonground` for the "5-lagen model". Specimen: `preview/components/showcase.html`.
64
68
  - **RotatingCards** — `RotatingCards/RotatingCards.jsx`. Rotating card carousel. Specimen: `preview/components/rotating-cards.html`.
65
69
  - **FAQ / FAQItem** — `FAQ/FAQ.jsx`. Disclosure-pattern FAQ block. Used on `/support`. Specimen: `preview/components/faq.html`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/docusaurus-preset",
3
- "version": "3.31.0",
3
+ "version": "3.32.0",
4
4
  "scripts": {
5
5
  "prepack": "node scripts/prepack-bundle-css.js",
6
6
  "test": "node --test"
@@ -0,0 +1,101 @@
1
+ /**
2
+ * <BuildMock />
3
+ *
4
+ * Token-built abstract of the OpenBuild "make it yours" moment: the
5
+ * orange edit button gets pressed, a small component palette opens, a
6
+ * widget is picked and dragged onto the page, and it snaps into the
7
+ * empty layout slot. Loops every ~8 seconds. Used on every app's
8
+ * Make-it-yours card.
9
+ *
10
+ * Same abstraction level as <AppMock>: greeked content, tokens only.
11
+ * The orange FAB is this component's single orange accent (it *is*
12
+ * the OpenBuild edit button).
13
+ *
14
+ * `prefers-reduced-motion: reduce` (and `running={false}`) renders
15
+ * the static end state: palette closed, cursor gone, the dragged
16
+ * component already snapped into the layout.
17
+ *
18
+ * Usage:
19
+ *
20
+ * <BuildMock />
21
+ * <BuildMock size="sm" caption />
22
+ * <BuildMock running={false} />
23
+ *
24
+ * Props:
25
+ * - size: 'sm' | 'md' (default) — frame width, as AppMock
26
+ * - caption: string | true — small label below the frame;
27
+ * `true` renders the default "Make it yours"
28
+ * - running: boolean (default true) — false freezes to the end state
29
+ * - className: string
30
+ */
31
+
32
+ import React from 'react';
33
+ import styles from './BuildMock.module.css';
34
+
35
+ function MiniTile({wide}) {
36
+ return (
37
+ <span className={styles.miniTile}>
38
+ <span className={styles.miniBar} style={{width: wide ? '70%' : '50%'}} />
39
+ <span className={styles.miniLine} />
40
+ <span className={styles.miniLine} style={{width: '60%'}} />
41
+ </span>
42
+ );
43
+ }
44
+
45
+ export default function BuildMock({size = 'md', caption, running = true, className}) {
46
+ return (
47
+ <div className={styles.bm}>
48
+ <figure className={[styles.figure, className].filter(Boolean).join(' ')}>
49
+ <div
50
+ className={[styles.frame, styles[`size-${size}`], !running && styles.static].filter(Boolean).join(' ')}
51
+ role="img"
52
+ aria-label="Abstract page editor: the edit button opens a component palette and a widget is dragged into the page layout."
53
+ >
54
+ <div className={styles.page}>
55
+ <div className={styles.pageHead}>
56
+ <span className={styles.headGreek} />
57
+ </div>
58
+ <div className={styles.pageGrid}>
59
+ <div className={styles.tile}><MiniTile wide /></div>
60
+ <div className={styles.tile}><MiniTile /></div>
61
+ <div className={styles.slot}>
62
+ {/* The component the drag delivers. Fades in when the
63
+ ghost lands; permanently visible in the static /
64
+ reduced-motion end state. */}
65
+ <div className={styles.snapTile}><MiniTile wide /></div>
66
+ </div>
67
+ <div className={styles.tile}><MiniTile /></div>
68
+ </div>
69
+ </div>
70
+
71
+ {/* Component palette, opened by the FAB press. */}
72
+ <div className={styles.palette}>
73
+ <span className={styles.paletteTile} />
74
+ <span className={[styles.paletteTile, styles.paletteTileActive].join(' ')} />
75
+ <span className={styles.paletteTile} />
76
+ </div>
77
+
78
+ {/* The OpenBuild edit button — the one orange accent. */}
79
+ <div className={styles.fab}>
80
+ <svg viewBox="0 0 24 24" aria-hidden="true">
81
+ <path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
82
+ </svg>
83
+ </div>
84
+
85
+ {/* The dragged component ghost. */}
86
+ <div className={styles.ghost}><MiniTile wide /></div>
87
+
88
+ {/* The cursor driving the loop. */}
89
+ <svg className={styles.cursor} viewBox="0 0 24 24" aria-hidden="true">
90
+ <path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
91
+ </svg>
92
+ </div>
93
+ {caption && (
94
+ <figcaption className={styles.caption}>
95
+ {caption === true ? 'Make it yours' : caption}
96
+ </figcaption>
97
+ )}
98
+ </figure>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * <BuildMock /> styles. Scoped under `.bm` (display: contents), the
3
+ * AppMock `.am` convention, so specimen pages can reuse the file as a
4
+ * plain global stylesheet.
5
+ *
6
+ * One shared timeline (--bm-dur, default 8s) drives five animations:
7
+ * the cursor path, the FAB press, the palette open/close, the dragged
8
+ * ghost, and the snap-in of the placed component. Percentages line up
9
+ * across the keyframe sets:
10
+ *
11
+ * 0–14% cursor travels to the FAB, presses it
12
+ * 16–22% palette opens, cursor moves to the middle palette tile
13
+ * 30–58% ghost is dragged from the palette to the empty slot
14
+ * 58–62% snap: ghost settles, placed component fades in
15
+ * 62–88% hold the result
16
+ * 88–100% everything resets for the next loop
17
+ *
18
+ * prefers-reduced-motion (and the .static class from running={false})
19
+ * freezes the mock at the end state: component placed, palette closed,
20
+ * no cursor. Tokens only; the orange FAB is the single orange accent.
21
+ */
22
+
23
+ .bm { display: contents; }
24
+
25
+ .bm .figure {
26
+ margin: 0;
27
+ display: inline-flex;
28
+ flex-direction: column;
29
+ gap: var(--space-2);
30
+ width: 100%;
31
+ max-width: 100%;
32
+ }
33
+
34
+ .bm .frame {
35
+ --bm-dur: 8s;
36
+ width: 100%;
37
+ max-width: 720px;
38
+ aspect-ratio: 16 / 10;
39
+ background: white;
40
+ border: 1px solid var(--c-cobalt-100);
41
+ border-radius: var(--radius-lg);
42
+ box-shadow: var(--shadow-3);
43
+ overflow: hidden;
44
+ position: relative;
45
+ font-family: var(--conduction-typography-font-family-body);
46
+ }
47
+ .bm .size-sm { max-width: 480px; }
48
+ .bm .size-md { max-width: 720px; }
49
+
50
+ .bm .caption {
51
+ font-family: var(--conduction-typography-font-family-code);
52
+ font-size: 11px;
53
+ letter-spacing: 0.06em;
54
+ color: var(--c-cobalt-400);
55
+ text-transform: uppercase;
56
+ text-align: center;
57
+ }
58
+
59
+ /* ---- Page skeleton ---- */
60
+
61
+ .bm .page {
62
+ position: absolute;
63
+ inset: 0;
64
+ padding: 7% 8%;
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 5%;
68
+ }
69
+ .bm .pageHead { flex-shrink: 0; }
70
+ .bm .headGreek {
71
+ display: block;
72
+ width: 34%;
73
+ height: 8px;
74
+ border-radius: 2px;
75
+ background: var(--c-cobalt-700);
76
+ }
77
+ .bm .pageGrid {
78
+ flex: 1;
79
+ display: grid;
80
+ grid-template-columns: 1fr 1fr;
81
+ grid-template-rows: 1fr 1fr;
82
+ gap: 5%;
83
+ /* Leave the right quarter free for the palette + FAB. */
84
+ margin-right: 22%;
85
+ }
86
+
87
+ .bm .tile,
88
+ .bm .slot {
89
+ border-radius: var(--radius-md);
90
+ padding: 8px 10px;
91
+ }
92
+ .bm .tile {
93
+ background: var(--c-cobalt-50);
94
+ border: 1px solid var(--c-cobalt-100);
95
+ }
96
+ .bm .slot {
97
+ border: 2px dashed var(--c-cobalt-200);
98
+ animation: bmSlotHint var(--bm-dur) linear infinite;
99
+ }
100
+
101
+ .bm .miniTile {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 5px;
105
+ width: 100%;
106
+ }
107
+ .bm .miniBar { display: block; height: 5px; border-radius: 1.5px; background: var(--c-cobalt-700); }
108
+ .bm .miniLine { display: block; height: 3px; border-radius: 1px; background: var(--c-cobalt-200); width: 80%; }
109
+
110
+ /* The delivered component inside the slot. */
111
+ .bm .snapTile {
112
+ opacity: 0;
113
+ animation: bmSnapIn var(--bm-dur) linear infinite;
114
+ }
115
+
116
+ /* ---- Palette ---- */
117
+
118
+ .bm .palette {
119
+ position: absolute;
120
+ right: 4%;
121
+ top: 22%;
122
+ width: 13%;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 8px;
126
+ background: white;
127
+ border: 1px solid var(--c-cobalt-100);
128
+ border-radius: var(--radius-md);
129
+ box-shadow: var(--shadow-3);
130
+ padding: 8px;
131
+ opacity: 0;
132
+ transform: translateX(10px);
133
+ animation: bmPalette var(--bm-dur) linear infinite;
134
+ }
135
+ .bm .paletteTile {
136
+ display: block;
137
+ height: 18px;
138
+ border-radius: 3px;
139
+ background: var(--c-cobalt-50);
140
+ border: 1px solid var(--c-cobalt-100);
141
+ }
142
+ .bm .paletteTileActive { animation: bmPaletteTile var(--bm-dur) linear infinite; }
143
+
144
+ /* ---- FAB (the one orange accent) ---- */
145
+
146
+ .bm .fab {
147
+ position: absolute;
148
+ right: 5%;
149
+ bottom: 7%;
150
+ width: 34px;
151
+ height: 34px;
152
+ border-radius: 50%;
153
+ background: var(--c-orange-knvb);
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ box-shadow: var(--shadow-2);
158
+ animation: bmFabPress var(--bm-dur) linear infinite;
159
+ }
160
+ .bm .fab svg {
161
+ width: 15px;
162
+ height: 15px;
163
+ fill: none;
164
+ stroke: white;
165
+ stroke-width: 2;
166
+ stroke-linecap: round;
167
+ stroke-linejoin: round;
168
+ }
169
+
170
+ /* ---- Dragged ghost ---- */
171
+
172
+ .bm .ghost {
173
+ position: absolute;
174
+ width: 22%;
175
+ left: 76%;
176
+ top: 30%;
177
+ background: white;
178
+ border: 1px solid var(--c-lavender-500);
179
+ border-radius: var(--radius-md);
180
+ box-shadow: var(--shadow-3);
181
+ padding: 8px 10px;
182
+ opacity: 0;
183
+ animation: bmGhost var(--bm-dur) linear infinite;
184
+ }
185
+
186
+ /* ---- Cursor ---- */
187
+
188
+ .bm .cursor {
189
+ position: absolute;
190
+ width: 16px;
191
+ height: 16px;
192
+ left: 55%;
193
+ top: 42%;
194
+ fill: var(--c-cobalt-900);
195
+ stroke: white;
196
+ stroke-width: 1;
197
+ filter: drop-shadow(0 1px 2px rgba(10, 23, 47, 0.35));
198
+ animation: bmCursor var(--bm-dur) linear infinite;
199
+ z-index: 2;
200
+ }
201
+
202
+ /* ---- Timeline keyframes ---- */
203
+
204
+ @keyframes bmCursor {
205
+ 0% { left: 55%; top: 42%; }
206
+ 12% { left: 88%; top: 82%; } /* over the FAB */
207
+ 16% { left: 88%; top: 82%; } /* press */
208
+ 24% { left: 82%; top: 36%; } /* over the middle palette tile */
209
+ 30% { left: 82%; top: 36%; } /* grab */
210
+ 58% { left: 26%; top: 66%; } /* drag to the slot */
211
+ 64% { left: 26%; top: 66%; } /* release */
212
+ 82% { left: 55%; top: 42%; }
213
+ 100% { left: 55%; top: 42%; }
214
+ }
215
+
216
+ @keyframes bmFabPress {
217
+ 0%, 11% { transform: scale(1); }
218
+ 13% { transform: scale(0.85); }
219
+ 16%, 100% { transform: scale(1); }
220
+ }
221
+
222
+ @keyframes bmPalette {
223
+ 0%, 15% { opacity: 0; transform: translateX(10px); }
224
+ 18%, 72% { opacity: 1; transform: translateX(0); }
225
+ 78%, 100% { opacity: 0; transform: translateX(10px); }
226
+ }
227
+
228
+ @keyframes bmPaletteTile {
229
+ 0%, 22% { background: var(--c-cobalt-50); border-color: var(--c-cobalt-100); }
230
+ 26%, 34% { background: var(--c-cobalt-100); border-color: var(--c-lavender-500); }
231
+ 38%, 100% { background: var(--c-cobalt-50); border-color: var(--c-cobalt-100); }
232
+ }
233
+
234
+ @keyframes bmGhost {
235
+ 0%, 29% { opacity: 0; left: 76%; top: 30%; transform: scale(1); }
236
+ 31% { opacity: 1; left: 76%; top: 30%; transform: scale(1.02); }
237
+ 58% { opacity: 1; left: 12%; top: 58%; transform: scale(1.02); }
238
+ 61% { opacity: 1; left: 12%; top: 60%; transform: scale(1); }
239
+ 63% { opacity: 0; left: 12%; top: 60%; transform: scale(1); }
240
+ 100% { opacity: 0; left: 12%; top: 60%; transform: scale(1); }
241
+ }
242
+
243
+ @keyframes bmSnapIn {
244
+ 0%, 60% { opacity: 0; }
245
+ 63%, 88% { opacity: 1; }
246
+ 94%, 100% { opacity: 0; }
247
+ }
248
+
249
+ @keyframes bmSlotHint {
250
+ 0%, 38% { border-color: var(--c-cobalt-200); }
251
+ 44%, 58% { border-color: var(--c-lavender-500); }
252
+ 63%, 100% { border-color: var(--c-cobalt-200); }
253
+ }
254
+
255
+ /* ---- Static end state (running={false} and reduced motion) ----
256
+ * Component placed, palette closed, no cursor, no ghost. */
257
+
258
+ .bm .static .snapTile,
259
+ .bm .static .slot,
260
+ .bm .static .fab,
261
+ .bm .static .palette,
262
+ .bm .static .paletteTile,
263
+ .bm .static .ghost,
264
+ .bm .static .cursor { animation: none; }
265
+ .bm .static .snapTile { opacity: 1; }
266
+ .bm .static .slot {
267
+ border-style: solid;
268
+ border-color: var(--c-cobalt-100);
269
+ border-width: 1px;
270
+ background: var(--c-cobalt-50);
271
+ }
272
+ .bm .static .palette, .bm .static .ghost, .bm .static .cursor { opacity: 0; visibility: hidden; }
273
+
274
+ @media (prefers-reduced-motion: reduce) {
275
+ .bm .snapTile,
276
+ .bm .slot,
277
+ .bm .fab,
278
+ .bm .palette,
279
+ .bm .paletteTile,
280
+ .bm .ghost,
281
+ .bm .cursor { animation: none; }
282
+ .bm .snapTile { opacity: 1; }
283
+ .bm .slot {
284
+ border-style: solid;
285
+ border-color: var(--c-cobalt-100);
286
+ border-width: 1px;
287
+ background: var(--c-cobalt-50);
288
+ }
289
+ .bm .palette, .bm .ghost, .bm .cursor { opacity: 0; visibility: hidden; }
290
+ }
@@ -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;