@conduction/docusaurus-preset 3.32.0 → 3.34.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 (31) hide show
  1. package/MISSING_COMPONENTS.md +7 -6
  2. package/package.json +1 -1
  3. package/src/components/AgentTrace/AgentTrace.jsx +26 -5
  4. package/src/components/AgentTrace/AgentTrace.module.css +41 -2
  5. package/src/components/AgentTrace/__tests__/AgentTrace.render.test.js +108 -0
  6. package/src/components/AppMock/AppMock.jsx +14 -5
  7. package/src/components/AppMock/AppMock.module.css +439 -0
  8. package/src/components/AppMock/__tests__/AppMock.render.test.js +181 -0
  9. package/src/components/AppMock/variants/LarpingAppMock.jsx +23 -4
  10. package/src/components/AppMock/variants/LaunchPadBiMock.jsx +12 -5
  11. package/src/components/AppMock/variants/LaunchPadTilesMock.jsx +6 -1
  12. package/src/components/AppMock/variants/LaunchPadWidgetsMock.jsx +17 -8
  13. package/src/components/AppMock/variants/NLDesignMock.jsx +15 -4
  14. package/src/components/AppMock/variants/PipelinQMock.jsx +12 -2
  15. package/src/components/AppMock/variants/PortaliqMock.jsx +21 -9
  16. package/src/components/AppMock/variants/ScholiqMock.jsx +12 -6
  17. package/src/components/FlowMock/FlowMock.jsx +5 -3
  18. package/src/components/FlowMock/FlowMock.module.css +36 -9
  19. package/src/components/FlowMock/__tests__/FlowMock.render.test.js +83 -0
  20. package/src/components/KanbanMock/KanbanMock.jsx +35 -5
  21. package/src/components/KanbanMock/KanbanMock.module.css +120 -28
  22. package/src/components/KanbanMock/__tests__/KanbanMock.render.test.js +114 -0
  23. package/src/components/LeafMock/LeafMock.jsx +23 -8
  24. package/src/components/LeafMock/LeafMock.module.css +123 -0
  25. package/src/components/LeafMock/__tests__/LeafMock.render.test.js +105 -0
  26. package/src/components/MockScene/MockScene.jsx +16 -2
  27. package/src/components/MockScene/MockScene.module.css +22 -2
  28. package/src/components/MockScene/__tests__/MockScene.render.test.js +101 -0
  29. package/src/components/WidgetShelf/WidgetShelf.jsx +321 -28
  30. package/src/components/WidgetShelf/WidgetShelf.module.css +193 -1
  31. package/src/components/WidgetShelf/__tests__/WidgetShelf.render.test.js +149 -0
@@ -44,6 +44,12 @@
44
44
  * - items: Array of item descriptors (see above)
45
45
  * - width: scene width in px (default 960)
46
46
  * - height: scene height in px (default 420)
47
+ * - running: boolean (default true) — the scene assembles: items
48
+ * land one by one on a 220ms stagger (opacity + translateY only,
49
+ * so the inline left/top positioning is never fought), hold, and
50
+ * fade for the loop reset. The loop period derives from the item
51
+ * count (--ms-dur, set inline). `running={false}` and
52
+ * prefers-reduced-motion show the assembled scene.
47
53
  * - className: string
48
54
  */
49
55
 
@@ -64,16 +70,24 @@ function renderItem(item) {
64
70
  return item.node || null;
65
71
  }
66
72
 
67
- export default function MockScene({ items = [], width = 960, height = 420, className }) {
73
+ export default function MockScene({ items = [], width = 960, height = 420, running = true, className }) {
74
+ /* Landing timing: item i arrives at i × 220ms; ~5s hold after the
75
+ last landing, then a fade window before the loop restarts. */
76
+ const STEP = 220;
77
+ const duration = items.length * STEP + 5600;
68
78
  return (
69
79
  <div className={[amStyles.am, styles.scene, className].filter(Boolean).join(' ')}>
70
- <div className={styles.sceneFrame} style={{ width, height }}>
80
+ <div
81
+ className={[styles.sceneFrame, !running && styles.static].filter(Boolean).join(' ')}
82
+ style={{ width, height, '--ms-dur': `${duration}ms` }}
83
+ >
71
84
  {items.map((item, i) => {
72
85
  const wrapperStyle = {
73
86
  ...(item.style || {}),
74
87
  left: item.x || 0,
75
88
  top: item.y || 0,
76
89
  zIndex: item.z != null ? item.z : i,
90
+ animationDelay: `${i * STEP}ms`,
77
91
  };
78
92
  return (
79
93
  <div key={i} className={styles.sceneItem} style={wrapperStyle}>
@@ -21,8 +21,28 @@
21
21
 
22
22
  .am .sceneItem {
23
23
  position: absolute;
24
- /* `left`, `top`, and optionally `z-index` are set inline by the
25
- component from the item's x/y/z values. */
24
+ /* `left`, `top`, optionally `z-index`, and the landing
25
+ animation-delay (i × 220ms) are set inline by the component from
26
+ the item's x/y/z values and index. The landing is transform +
27
+ opacity only — the inline left/top positioning is never fought.
28
+ 0% and 100% are both hidden, so the staggered delays stay
29
+ seamless in steady state; `both` keeps a delayed item on its
30
+ hidden first frame before the initial landing. */
31
+ animation: msLand var(--ms-dur, 8s) cubic-bezier(0.2, 0.7, 0.3, 1) infinite both;
32
+ }
33
+
34
+ @keyframes msLand {
35
+ 0% { opacity: 0; transform: translateY(16px); }
36
+ 4% { opacity: 1; transform: none; }
37
+ 94% { opacity: 1; transform: none; }
38
+ 99%, 100% { opacity: 0; transform: none; }
39
+ }
40
+
41
+ /* Static scene (running={false} and reduced motion): assembled. */
42
+ .am .static .sceneItem { animation: none; }
43
+
44
+ @media (prefers-reduced-motion: reduce) {
45
+ .am .sceneItem { animation: none; }
26
46
  }
27
47
 
28
48
  /* Widgets inside a scene drop their LaunchPad-style cobalt-900 dashboard
@@ -0,0 +1,101 @@
1
+ /**
2
+ * MockScene.render.test.js — SSR-renders the real <MockScene> and
3
+ * asserts the landing-stagger wiring: inline left/top positioning is
4
+ * preserved untouched (the animation is transform-only), each item
5
+ * carries its 220ms-stagger animation-delay, the loop period --ms-dur
6
+ * derives from the item count, and running={false} puts the static
7
+ * class on the scene frame. Same esbuild harness as
8
+ * KanbanMock.render.test.js; CSS modules stubbed to identity.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const test = require('node:test');
14
+ const {before, after} = test;
15
+ const assert = require('node:assert/strict');
16
+ const path = require('node:path');
17
+ const fs = require('node:fs/promises');
18
+ const {build} = require('esbuild');
19
+ const React = require('react');
20
+ const {renderToStaticMarkup} = require('react-dom/server');
21
+
22
+ const COMPONENT = path.resolve(__dirname, '..', 'MockScene.jsx');
23
+ const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
24
+ const SCRATCH = path.join(PRESET_ROOT, '.tmp-mock-scene-test');
25
+
26
+ const ITEMS = [
27
+ {type: 'widget', kind: 'nextcloud-mail', x: 0, y: 0, size: 'sm'},
28
+ {type: 'sidebar', kind: 'openregister-metadata', x: 220, y: 30, size: 'md'},
29
+ {type: 'widget', kind: 'openregister-activity', x: 540, y: 80, size: 'md'},
30
+ ];
31
+
32
+ const cssModuleStub = {
33
+ name: 'css-module-stub',
34
+ setup(b) {
35
+ b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
36
+ b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
37
+ contents: 'export default new Proxy({}, {get: (_, p) => p});',
38
+ loader: 'js',
39
+ }));
40
+ },
41
+ };
42
+
43
+ let MockScene;
44
+
45
+ before(async () => {
46
+ await fs.mkdir(SCRATCH, {recursive: true});
47
+ const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
48
+ const outFile = path.join(tmpDir, 'bundle.cjs');
49
+ await build({
50
+ entryPoints: [COMPONENT],
51
+ outfile: outFile,
52
+ bundle: true,
53
+ format: 'cjs',
54
+ jsx: 'automatic',
55
+ jsxImportSource: 'react',
56
+ tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
57
+ platform: 'node',
58
+ external: ['react'],
59
+ plugins: [cssModuleStub],
60
+ logLevel: 'warning',
61
+ });
62
+ delete require.cache[require.resolve(outFile)];
63
+ MockScene = require(outFile).default;
64
+ });
65
+
66
+ after(async () => {
67
+ await fs.rm(SCRATCH, {recursive: true, force: true});
68
+ });
69
+
70
+ function render(props) {
71
+ return renderToStaticMarkup(React.createElement(MockScene, props));
72
+ }
73
+
74
+ test('SSR smoke: renders the scene frame with one wrapper per item', () => {
75
+ const html = render({items: ITEMS});
76
+ assert.match(html, /class="sceneFrame"/);
77
+ assert.equal((html.match(/class="sceneItem"/g) || []).length, ITEMS.length);
78
+ });
79
+
80
+ test('positioning: inline left/top from item x/y are preserved (animation is transform-only)', () => {
81
+ const html = render({items: ITEMS});
82
+ assert.match(html, /left:220px;top:30px/);
83
+ assert.match(html, /left:540px;top:80px/);
84
+ });
85
+
86
+ test('landing stagger: each item carries its 220ms-step animation-delay', () => {
87
+ const html = render({items: ITEMS});
88
+ for (let i = 0; i < ITEMS.length; i++) {
89
+ assert.match(html, new RegExp(`animation-delay:${i * 220}ms`), `item ${i}: missing delay`);
90
+ }
91
+ });
92
+
93
+ test('loop period: --ms-dur on the frame derives from the item count (+ hold)', () => {
94
+ const html = render({items: ITEMS});
95
+ assert.match(html, new RegExp(`--ms-dur:${ITEMS.length * 220 + 5600}ms`));
96
+ });
97
+
98
+ test('P0 plumbing: running={false} adds the static class to the scene frame; default omits it', () => {
99
+ assert.match(render({items: ITEMS, running: false}), /class="sceneFrame static"/);
100
+ assert.doesNotMatch(render({items: ITEMS}), /sceneFrame static/);
101
+ });
@@ -37,14 +37,187 @@
37
37
  * viewport. Each card has the panel (preview) at the top, title +
38
38
  * description below.
39
39
  *
40
+ * Widgets without an explicit `panel` get an auto-generated mini
41
+ * panel: the title is hashed into one of four token-built archetypes
42
+ * (KPI tile, mini bar chart, list rows, donut gauge) with a
43
+ * deterministically chosen accent family, so long carousels look
44
+ * alive without hand-built mocks. An explicit `panel` always wins.
45
+ * Same abstraction level as <AppMock>: greeked bars, no real words.
46
+ *
47
+ * Controls: the carousel renders a pause/play toggle and prev/next
48
+ * buttons (cobalt ghost styling). Prev/next nudge the track by one
49
+ * card with a smooth ease; auto-scroll resumes afterwards unless
50
+ * paused. Server-side and before hydration the track runs on the
51
+ * pure-CSS marquee; after mount a rAF loop drives the same transform
52
+ * so the buttons can steer it. Under reduced motion the controls are
53
+ * hidden along with the marquee.
54
+ *
40
55
  * Props (beyond eyebrow/title/lede/widgets/columns/className):
41
56
  * - carousel: boolean (default true) — false renders the static grid
42
- * - speed: number — seconds per full carousel loop (default 45)
57
+ * - speed: number — seconds per full carousel loop. Default is
58
+ * derived from the widget count (6s per card, min 30s)
59
+ * so the per-card reading pace stays constant no matter
60
+ * how many widgets a page declares.
43
61
  */
44
62
 
45
- import React from 'react';
63
+ import React, {useEffect, useRef, useState} from 'react';
46
64
  import styles from './WidgetShelf.module.css';
47
65
 
66
+ /* Seconds each card takes to traverse its own width — the per-card
67
+ reading pace. A 15-widget shelf loops in 90s, a 30-widget one in
68
+ 180s; both feel identical to the reader. */
69
+ const SECONDS_PER_CARD = 6;
70
+ const MIN_LOOP_SECONDS = 30;
71
+ const NUDGE_MS = 450;
72
+
73
+ /* Accent families available to auto-generated panels — exactly the
74
+ hex-family policy roster from tokens.css: lavender, mint, forest,
75
+ terracotta, plus workspaceblue. Coral (KNVB orange) and gold are
76
+ reserved (one orange per component — the shelf's eyebrow already
77
+ spends it; gold is the Certified mark only). */
78
+ const AUTO_FAMILIES = ['mint', 'lavender', 'forest', 'terracotta', 'workspaceblue'];
79
+
80
+ /* djb2-xor string hash with a murmur3-style finalizer — stable across
81
+ renders and platforms, so the same title always draws the same
82
+ panel. The finalizer avalanches the bits: without it, titles that
83
+ differ only in their last character ("Lead 1" / "Lead 2") land in
84
+ the same accent family because the family picker discards low bits. */
85
+ function hashString(str) {
86
+ let h = 5381;
87
+ for (let i = 0; i < str.length; i++) {
88
+ h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
89
+ }
90
+ h ^= h >>> 16;
91
+ h = Math.imul(h, 0x85ebca6b) >>> 0;
92
+ h ^= h >>> 13;
93
+ h = Math.imul(h, 0xc2b2ae35) >>> 0;
94
+ h ^= h >>> 16;
95
+ return h >>> 0;
96
+ }
97
+
98
+ /**
99
+ * Auto-generated mini panel for widgets without an explicit `panel`.
100
+ * Deterministic: archetype and accent family both derive from the
101
+ * hash of the seed (the widget title). Token-built, greeked, no
102
+ * words — the card below already shows the real title.
103
+ */
104
+ function AutoPanel({seed}) {
105
+ const h = hashString(seed);
106
+ const family = AUTO_FAMILIES[(h >>> 4) % AUTO_FAMILIES.length];
107
+ const vars = {
108
+ '--wsa': `var(--c-${family}-500)`,
109
+ '--wsa-soft': `var(--c-${family}-300)`,
110
+ };
111
+ const kind = h % 4;
112
+
113
+ if (kind === 0) {
114
+ /* KPI number tile. */
115
+ const value = 12 + (h % 88);
116
+ const trendW = 32 + ((h >>> 8) % 40);
117
+ return (
118
+ <div className={[styles.auto, styles.autoKpi].join(' ')} style={vars} aria-hidden="true">
119
+ <span className={styles.autoHex} />
120
+ <span className={styles.autoKpiValue}>{value}</span>
121
+ <span className={styles.autoKpiBar} style={{width: `${trendW}%`}} />
122
+ </div>
123
+ );
124
+ }
125
+
126
+ if (kind === 1) {
127
+ /* Mini bar chart. */
128
+ const hi = (h >>> 8) % 6;
129
+ return (
130
+ <div className={[styles.auto, styles.autoBars].join(' ')} style={vars} aria-hidden="true">
131
+ {Array.from({length: 6}, (_, i) => {
132
+ const height = Math.round(30 + (((h >>> (i * 4)) & 15) / 15) * 65);
133
+ return (
134
+ <span
135
+ key={i}
136
+ className={[styles.autoBar, i === hi && styles.autoBarHi].filter(Boolean).join(' ')}
137
+ style={{height: `${height}%`}}
138
+ />
139
+ );
140
+ })}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ if (kind === 2) {
146
+ /* List rows. */
147
+ const hot = (h >>> 8) % 3;
148
+ return (
149
+ <div className={[styles.auto, styles.autoList].join(' ')} style={vars} aria-hidden="true">
150
+ {Array.from({length: 3}, (_, i) => {
151
+ const w = Math.round(34 + (((h >>> (i * 5)) & 31) / 31) * 30);
152
+ return (
153
+ <span key={i} className={styles.autoRow}>
154
+ <span className={[styles.autoDot, i === hot && styles.autoDotHi].filter(Boolean).join(' ')} />
155
+ <span className={styles.autoRowBars}>
156
+ <span className={styles.autoRowBar} style={{width: `${w}%`}} />
157
+ <span className={styles.autoRowLine} />
158
+ </span>
159
+ </span>
160
+ );
161
+ })}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ /* Donut gauge. Circumference of r=15.9155 is 100, so the dasharray
167
+ reads directly as a percentage. */
168
+ const pct = 34 + ((h >>> 6) % 52);
169
+ return (
170
+ <div className={[styles.auto, styles.autoDonut].join(' ')} style={vars} aria-hidden="true">
171
+ <svg viewBox="0 0 42 42" className={styles.autoRing} aria-hidden="true" focusable="false">
172
+ <circle cx="21" cy="21" r="15.9155" className={styles.autoRingTrack} />
173
+ <circle
174
+ cx="21"
175
+ cy="21"
176
+ r="15.9155"
177
+ className={styles.autoRingFill}
178
+ strokeDasharray={`${pct} ${100 - pct}`}
179
+ strokeDashoffset="25"
180
+ />
181
+ </svg>
182
+ <span className={styles.autoDonutBars}>
183
+ <span className={styles.autoRowBar} style={{width: '64%'}} />
184
+ <span className={styles.autoRowLine} />
185
+ </span>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ function ControlIcon({kind}) {
191
+ if (kind === 'prev') {
192
+ return (
193
+ <svg viewBox="0 0 24 24" className={styles.chev} aria-hidden="true" focusable="false">
194
+ <path d="M15 4l-8 8 8 8" />
195
+ </svg>
196
+ );
197
+ }
198
+ if (kind === 'next') {
199
+ return (
200
+ <svg viewBox="0 0 24 24" className={styles.chev} aria-hidden="true" focusable="false">
201
+ <path d="M9 4l8 8-8 8" />
202
+ </svg>
203
+ );
204
+ }
205
+ if (kind === 'pause') {
206
+ return (
207
+ <svg viewBox="0 0 24 24" className={styles.glyph} aria-hidden="true" focusable="false">
208
+ <rect x="6" y="4" width="4" height="16" rx="1" />
209
+ <rect x="14" y="4" width="4" height="16" rx="1" />
210
+ </svg>
211
+ );
212
+ }
213
+ /* play */
214
+ return (
215
+ <svg viewBox="0 0 24 24" className={styles.glyph} aria-hidden="true" focusable="false">
216
+ <path d="M8 4.5l12 7.5-12 7.5z" />
217
+ </svg>
218
+ );
219
+ }
220
+
48
221
  export default function WidgetShelf({
49
222
  eyebrow,
50
223
  title,
@@ -52,17 +225,114 @@ export default function WidgetShelf({
52
225
  widgets = [],
53
226
  columns,
54
227
  carousel = true,
55
- speed = 45,
228
+ speed,
56
229
  className,
57
230
  }) {
58
231
  const cols = columns || (widgets.length >= 4 ? 3 : Math.min(widgets.length, 3));
59
- const cards = widgets.map((w, i) => (
232
+ const loopSeconds = speed != null ? speed : Math.max(MIN_LOOP_SECONDS, widgets.length * SECONDS_PER_CARD);
233
+
234
+ const [paused, setPaused] = useState(false);
235
+ const pausedRef = useRef(false);
236
+ const viewportRef = useRef(null);
237
+ const trackRef = useRef(null);
238
+ const groupRef = useRef(null);
239
+ /* Marquee state lives in a ref: the rAF loop writes the transform
240
+ directly, so no React re-render per frame. */
241
+ const marquee = useRef({offset: 0, groupW: 0, step: 0, hover: false, nudge: null});
242
+
243
+ useEffect(() => {
244
+ pausedRef.current = paused;
245
+ }, [paused]);
246
+
247
+ useEffect(() => {
248
+ if (!carousel || widgets.length === 0) return undefined;
249
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
250
+ /* Reduced motion: the CSS already collapses the carousel to the
251
+ static grid and hides the controls; nothing to drive. */
252
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return undefined;
253
+
254
+ const viewport = viewportRef.current;
255
+ const track = trackRef.current;
256
+ const group = groupRef.current;
257
+ if (!viewport || !track || !group) return undefined;
258
+
259
+ const m = marquee.current;
260
+ const measure = () => {
261
+ /* offsetWidth includes the trailing padding-right gap, so it is
262
+ exactly the seamless-loop period. */
263
+ m.groupW = group.offsetWidth;
264
+ const cards = group.children;
265
+ m.step = cards.length > 1 ? cards[1].offsetLeft - cards[0].offsetLeft : group.offsetWidth;
266
+ };
267
+ measure();
268
+
269
+ /* Take over from the pure-CSS marquee (which covers SSR/no-JS). */
270
+ track.classList.add(styles.jsDriven);
271
+
272
+ const onEnter = () => { m.hover = true; };
273
+ const onLeave = () => { m.hover = false; };
274
+ viewport.addEventListener('mouseenter', onEnter);
275
+ viewport.addEventListener('mouseleave', onLeave);
276
+ viewport.addEventListener('focusin', onEnter);
277
+ viewport.addEventListener('focusout', onLeave);
278
+
279
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
280
+ if (ro) ro.observe(group);
281
+
282
+ let raf = 0;
283
+ let last = performance.now();
284
+ const frame = (now) => {
285
+ const dt = Math.min((now - last) / 1000, 0.1);
286
+ last = now;
287
+ if (m.nudge) {
288
+ const t = Math.min((now - m.nudge.start) / m.nudge.dur, 1);
289
+ /* ease-in-out cubic */
290
+ const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
291
+ m.offset = m.nudge.from + (m.nudge.to - m.nudge.from) * e;
292
+ if (t >= 1) m.nudge = null;
293
+ } else if (!pausedRef.current && !m.hover) {
294
+ m.offset += (m.groupW / loopSeconds) * dt;
295
+ }
296
+ if (m.groupW > 0) {
297
+ /* Content repeats every groupW px, so wrapping is invisible. */
298
+ m.offset = ((m.offset % m.groupW) + m.groupW) % m.groupW;
299
+ }
300
+ track.style.transform = `translate3d(${-m.offset}px, 0, 0)`;
301
+ raf = window.requestAnimationFrame(frame);
302
+ };
303
+ raf = window.requestAnimationFrame(frame);
304
+
305
+ return () => {
306
+ window.cancelAnimationFrame(raf);
307
+ if (ro) ro.disconnect();
308
+ viewport.removeEventListener('mouseenter', onEnter);
309
+ viewport.removeEventListener('mouseleave', onLeave);
310
+ viewport.removeEventListener('focusin', onEnter);
311
+ viewport.removeEventListener('focusout', onLeave);
312
+ track.classList.remove(styles.jsDriven);
313
+ track.style.transform = '';
314
+ m.nudge = null;
315
+ };
316
+ }, [carousel, widgets.length, loopSeconds]);
317
+
318
+ const nudge = (dir) => {
319
+ const m = marquee.current;
320
+ if (!m.step) return;
321
+ /* Rapid clicks accumulate: continue from the pending target. */
322
+ const base = m.nudge ? m.nudge.to : m.offset;
323
+ m.nudge = {from: m.offset, to: base + dir * m.step, start: performance.now(), dur: NUDGE_MS};
324
+ };
325
+
326
+ const renderCard = (w, i) => (
60
327
  <article key={i} className={styles.card}>
61
- <div className={styles.panel}>{w.panel}</div>
328
+ <div className={styles.panel}>
329
+ {w.panel || <AutoPanel seed={w.title ? String(w.title) : `widget-${i}`} />}
330
+ </div>
62
331
  {w.title && <h3 className={styles.cardTitle}>{w.title}</h3>}
63
332
  {w.desc && <p className={styles.cardDesc}>{w.desc}</p>}
64
333
  </article>
65
- ));
334
+ );
335
+
66
336
  return (
67
337
  <section className={[styles.shelf, carousel && styles.carousel, className].filter(Boolean).join(' ')}>
68
338
  {(eyebrow || title || lede) && (
@@ -73,32 +343,55 @@ export default function WidgetShelf({
73
343
  </header>
74
344
  )}
75
345
  {carousel ? (
76
- <div className={styles.viewport}>
77
- <div
78
- className={styles.track}
79
- style={{'--ws-speed': `${speed}s`}}
80
- >
81
- <div className={[styles.group, styles[`cols-${cols}`]].join(' ')}>
82
- {cards}
83
- </div>
84
- {/* Seamless-loop duplicate. Hidden from assistive tech so
85
- each widget is announced once; under reduced motion the
86
- CSS removes it entirely and the first group becomes the
87
- static grid. */}
88
- <div className={[styles.group, styles.dup].join(' ')} aria-hidden="true">
89
- {widgets.map((w, i) => (
90
- <article key={i} className={styles.card}>
91
- <div className={styles.panel}>{w.panel}</div>
92
- {w.title && <h3 className={styles.cardTitle}>{w.title}</h3>}
93
- {w.desc && <p className={styles.cardDesc}>{w.desc}</p>}
94
- </article>
95
- ))}
346
+ <>
347
+ <div className={styles.viewport} ref={viewportRef}>
348
+ <div
349
+ className={styles.track}
350
+ ref={trackRef}
351
+ style={{'--ws-speed': `${loopSeconds}s`}}
352
+ >
353
+ <div className={[styles.group, styles[`cols-${cols}`]].join(' ')} ref={groupRef}>
354
+ {widgets.map(renderCard)}
355
+ </div>
356
+ {/* Seamless-loop duplicate. Hidden from assistive tech so
357
+ each widget is announced once; under reduced motion the
358
+ CSS removes it entirely and the first group becomes the
359
+ static grid. */}
360
+ <div className={[styles.group, styles.dup].join(' ')} aria-hidden="true">
361
+ {widgets.map(renderCard)}
362
+ </div>
96
363
  </div>
97
364
  </div>
98
- </div>
365
+ <div className={styles.controls}>
366
+ <button
367
+ type="button"
368
+ className={styles.ctrl}
369
+ aria-label="Previous widgets"
370
+ onClick={() => nudge(-1)}
371
+ >
372
+ <ControlIcon kind="prev" />
373
+ </button>
374
+ <button
375
+ type="button"
376
+ className={styles.ctrl}
377
+ aria-label={paused ? 'Play' : 'Pause'}
378
+ onClick={() => setPaused((p) => !p)}
379
+ >
380
+ <ControlIcon kind={paused ? 'play' : 'pause'} />
381
+ </button>
382
+ <button
383
+ type="button"
384
+ className={styles.ctrl}
385
+ aria-label="Next widgets"
386
+ onClick={() => nudge(1)}
387
+ >
388
+ <ControlIcon kind="next" />
389
+ </button>
390
+ </div>
391
+ </>
99
392
  ) : (
100
393
  <div className={[styles.grid, styles[`cols-${cols}`]].join(' ')}>
101
- {cards}
394
+ {widgets.map(renderCard)}
102
395
  </div>
103
396
  )}
104
397
  </section>