@conduction/docusaurus-preset 3.32.0 → 3.33.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.
- package/MISSING_COMPONENTS.md +2 -2
- package/package.json +1 -1
- package/src/components/KanbanMock/KanbanMock.jsx +35 -5
- package/src/components/KanbanMock/KanbanMock.module.css +120 -28
- package/src/components/KanbanMock/__tests__/KanbanMock.render.test.js +114 -0
- package/src/components/WidgetShelf/WidgetShelf.jsx +321 -28
- package/src/components/WidgetShelf/WidgetShelf.module.css +193 -1
- package/src/components/WidgetShelf/__tests__/WidgetShelf.render.test.js +149 -0
package/MISSING_COMPONENTS.md
CHANGED
|
@@ -61,9 +61,9 @@ Most consume one or more primitives.
|
|
|
61
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
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
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.
|
|
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 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.
|
|
65
65
|
- **ExternalAppShelf** — `ExternalAppShelf/ExternalAppShelf.jsx`. Third-party-app shelf (Outlook-style, Mattermost, Keycloak, etc.) used on `/connext`. Specimen: `preview/components/external-app-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`.
|
|
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`); 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`.
|
|
67
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`.
|
|
68
68
|
- **RotatingCards** — `RotatingCards/RotatingCards.jsx`. Rotating card carousel. Specimen: `preview/components/rotating-cards.html`.
|
|
69
69
|
- **FAQ / FAQItem** — `FAQ/FAQ.jsx`. Disclosure-pattern FAQ block. Used on `/support`. Specimen: `preview/components/faq.html`.
|
package/package.json
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Token-built abstract of the pipelinq kanban board with a looping
|
|
5
5
|
* drag animation: a cursor picks up the top lead card in the first
|
|
6
6
|
* column (the card lifts), drags it to the second column, drops it,
|
|
7
|
-
* and the column counts nudge (3→2, 2→3)
|
|
8
|
-
*
|
|
7
|
+
* and at the drop moment the column counts nudge (3→2, 2→3) and the
|
|
8
|
+
* per-lane € KPI tiles above the board shift the moved deal's value
|
|
9
|
+
* (€ 48k→€ 30k, € 27k→€ 45k; the third lane stays € 64k). Loops
|
|
10
|
+
* every ~7 seconds and resets cleanly at loop start. Pipelinq's
|
|
11
|
+
* hero/panel illustration.
|
|
9
12
|
*
|
|
10
13
|
* Named KanbanMock rather than PipelineMock because the preset
|
|
11
14
|
* already ships a `Pipeline` component (the hex-numbered process
|
|
@@ -48,8 +51,8 @@ function CardGreek({wide, dot}) {
|
|
|
48
51
|
|
|
49
52
|
function Count({from, to}) {
|
|
50
53
|
/* Two stacked digits crossfading on the shared timeline: `from`
|
|
51
|
-
shows until the drop (
|
|
52
|
-
next loop. Static renderings show `from`. */
|
|
54
|
+
shows until the drop settles (54%), `to` after it, back again for
|
|
55
|
+
the next loop. Static renderings show `from`. */
|
|
53
56
|
return (
|
|
54
57
|
<span className={styles.count}>
|
|
55
58
|
<span className={styles.countBefore}>{from}</span>
|
|
@@ -58,6 +61,28 @@ function Count({from, to}) {
|
|
|
58
61
|
);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
function Kpi({from, to}) {
|
|
65
|
+
/* Small € tile above a lane. Reuses the count crossfade classes so
|
|
66
|
+
the value swaps at the same drop moment as the column counts and
|
|
67
|
+
shows `from` in static renderings. A `to` of undefined renders a
|
|
68
|
+
lane whose total never changes. */
|
|
69
|
+
return (
|
|
70
|
+
<div className={styles.kpi}>
|
|
71
|
+
<span className={styles.kpiBar} />
|
|
72
|
+
<span className={styles.kpiValue}>
|
|
73
|
+
{to != null ? (
|
|
74
|
+
<>
|
|
75
|
+
<span className={styles.countBefore}>{from}</span>
|
|
76
|
+
<span className={styles.countAfter}>{to}</span>
|
|
77
|
+
</>
|
|
78
|
+
) : (
|
|
79
|
+
<span className={styles.kpiStill}>{from}</span>
|
|
80
|
+
)}
|
|
81
|
+
</span>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
export default function KanbanMock({size = 'md', caption, running = true, className}) {
|
|
62
87
|
return (
|
|
63
88
|
<div className={styles.km}>
|
|
@@ -65,9 +90,14 @@ export default function KanbanMock({size = 'md', caption, running = true, classN
|
|
|
65
90
|
<div
|
|
66
91
|
className={[styles.frame, styles[`size-${size}`], !running && styles.static].filter(Boolean).join(' ')}
|
|
67
92
|
role="img"
|
|
68
|
-
aria-label="Abstract kanban board: a lead card is dragged from the first column to the second
|
|
93
|
+
aria-label="Abstract kanban board: a lead card is dragged from the first column to the second; the column counts and the lane value totals update at the drop."
|
|
69
94
|
>
|
|
70
95
|
<div className={styles.board}>
|
|
96
|
+
<div className={styles.kpis}>
|
|
97
|
+
<Kpi from="€ 48k" to="€ 30k" />
|
|
98
|
+
<Kpi from="€ 27k" to="€ 45k" />
|
|
99
|
+
<Kpi from="€ 64k" />
|
|
100
|
+
</div>
|
|
71
101
|
<div className={styles.col}>
|
|
72
102
|
<div className={styles.colHead}>
|
|
73
103
|
<span className={styles.colBar} />
|
|
@@ -5,17 +5,32 @@
|
|
|
5
5
|
*
|
|
6
6
|
* One shared timeline (--km-dur, default 7s):
|
|
7
7
|
*
|
|
8
|
-
* 0–
|
|
8
|
+
* 0–10% cursor travels to the top card in column 1
|
|
9
9
|
* 14% the card lifts (scale + deeper shadow)
|
|
10
10
|
* 16–50% card + cursor drag to column 2's top slot
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 50–54% the card settles into the slot
|
|
12
|
+
* 54–56% the column counts (3→2, 2→3) and the lane € KPIs
|
|
13
|
+
* (€ 48k→€ 30k, € 27k→€ 45k) crossfade — right at the drop
|
|
14
|
+
* 56–86% hold
|
|
15
|
+
* 86–100% card fades back to its origin, counts + KPIs revert, loop
|
|
16
|
+
*
|
|
17
|
+
* Geometry: the mover is absolutely positioned over the board, so its
|
|
18
|
+
* keyframe coordinates must equal the top card slot of columns 1 and
|
|
19
|
+
* 2. Those slots mix %-sized board chrome with px-sized column chrome,
|
|
20
|
+
* so the coordinates are calc() custom properties (--km-x1, --km-x2,
|
|
21
|
+
* --km-y, --km-card-w) derived below rather than by-eye percentages —
|
|
22
|
+
* that keeps the landing exact at every frame width. Derivation:
|
|
23
|
+
* board padding 6%/5% of frame width W; column gap 4% of the 90%
|
|
24
|
+
* content box = 3.6%W; columns (90% − 7.2%)/3 = 27.6%W;
|
|
25
|
+
* col chrome = 1px border + 10px padding = 11px;
|
|
26
|
+
* vertical: 6%W top pad = 9.6% of the 16/10 frame height, + KPI row
|
|
27
|
+
* 34px + 12px row gap + col chrome 11px + head 16px + head margin
|
|
28
|
+
* 2px + col gap 8px = 83px.
|
|
14
29
|
*
|
|
15
30
|
* prefers-reduced-motion (and .static from running={false}) renders
|
|
16
31
|
* the static board: no cursor, no animation, the card resting in its
|
|
17
|
-
* origin column, original counts. Tokens only; the
|
|
18
|
-
* dot (mint) is the only non-neutral accent; no orange.
|
|
32
|
+
* origin column, original counts and KPI values. Tokens only; the
|
|
33
|
+
* mover's status dot (mint) is the only non-neutral accent; no orange.
|
|
19
34
|
*/
|
|
20
35
|
|
|
21
36
|
.km { display: contents; }
|
|
@@ -31,6 +46,11 @@
|
|
|
31
46
|
|
|
32
47
|
.km .frame {
|
|
33
48
|
--km-dur: 7s;
|
|
49
|
+
/* Card-slot geometry (see the derivation in the header comment). */
|
|
50
|
+
--km-x1: calc(5% + 11px);
|
|
51
|
+
--km-x2: calc(36.2% + 11px);
|
|
52
|
+
--km-y: calc(9.6% + 83px);
|
|
53
|
+
--km-card-w: calc(27.6% - 22px);
|
|
34
54
|
width: 100%;
|
|
35
55
|
max-width: 720px;
|
|
36
56
|
aspect-ratio: 16 / 10;
|
|
@@ -62,8 +82,71 @@
|
|
|
62
82
|
padding: 6% 5%;
|
|
63
83
|
display: grid;
|
|
64
84
|
grid-template-columns: repeat(3, 1fr);
|
|
85
|
+
grid-template-rows: auto 1fr;
|
|
86
|
+
/* Fixed 12px row gap (part of the mover's --km-y derivation);
|
|
87
|
+
percentage column gap so lanes track the frame width. */
|
|
88
|
+
gap: 12px 4%;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---- KPI row above the lanes ---- */
|
|
92
|
+
|
|
93
|
+
.km .kpis {
|
|
94
|
+
grid-column: 1 / -1;
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-columns: repeat(3, 1fr);
|
|
97
|
+
/* Same content box as the board, so 4% aligns tiles with lanes. */
|
|
65
98
|
gap: 4%;
|
|
99
|
+
min-width: 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.km .kpi {
|
|
103
|
+
box-sizing: border-box;
|
|
104
|
+
height: 34px; /* fixed: part of the mover's --km-y derivation */
|
|
105
|
+
background: white;
|
|
106
|
+
border: 1px solid var(--c-cobalt-100);
|
|
107
|
+
border-radius: var(--radius-md);
|
|
108
|
+
box-shadow: var(--shadow-1);
|
|
109
|
+
padding: 0 10px;
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
gap: 8px;
|
|
114
|
+
min-width: 0;
|
|
115
|
+
}
|
|
116
|
+
.km .kpiBar {
|
|
117
|
+
display: block;
|
|
118
|
+
width: 26%;
|
|
119
|
+
height: 4px;
|
|
120
|
+
border-radius: 1px;
|
|
121
|
+
background: var(--c-cobalt-300);
|
|
122
|
+
flex-shrink: 0;
|
|
123
|
+
}
|
|
124
|
+
.km .kpiValue {
|
|
125
|
+
position: relative;
|
|
126
|
+
display: inline-flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: flex-end;
|
|
129
|
+
min-width: 38px;
|
|
130
|
+
height: 14px;
|
|
131
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
color: var(--c-cobalt-900);
|
|
135
|
+
white-space: nowrap;
|
|
136
|
+
}
|
|
137
|
+
/* The crossfading value spans reuse .countBefore/.countAfter (same
|
|
138
|
+
timeline, same static overrides) but sit right-aligned in the tile
|
|
139
|
+
instead of filling a pill. */
|
|
140
|
+
.km .kpiValue .countBefore,
|
|
141
|
+
.km .kpiValue .countAfter {
|
|
142
|
+
inset: auto;
|
|
143
|
+
right: 0;
|
|
144
|
+
top: 50%;
|
|
145
|
+
transform: translateY(-50%);
|
|
146
|
+
white-space: nowrap;
|
|
66
147
|
}
|
|
148
|
+
/* A lane total that never changes (third lane). */
|
|
149
|
+
.km .kpiStill { display: inline-flex; align-items: center; }
|
|
67
150
|
|
|
68
151
|
.km .col {
|
|
69
152
|
background: var(--c-cobalt-50);
|
|
@@ -124,6 +207,9 @@
|
|
|
124
207
|
|
|
125
208
|
.km .card,
|
|
126
209
|
.km .mover {
|
|
210
|
+
/* Explicit border-box: --km-card-w describes the full card box, and
|
|
211
|
+
not every consumer of this file ships a global box-sizing reset. */
|
|
212
|
+
box-sizing: border-box;
|
|
127
213
|
background: white;
|
|
128
214
|
border: 1px solid var(--c-cobalt-100);
|
|
129
215
|
border-radius: var(--radius-sm);
|
|
@@ -134,9 +220,10 @@
|
|
|
134
220
|
gap: 5px;
|
|
135
221
|
}
|
|
136
222
|
|
|
137
|
-
/* The gap where the travelling card lives at rest.
|
|
138
|
-
|
|
139
|
-
|
|
223
|
+
/* The gap where the travelling card lives at rest. Exactly the
|
|
224
|
+
mover's height (2px border + 16px padding + 23px content) so the
|
|
225
|
+
card sits flush in the slot when it lands. */
|
|
226
|
+
.km .slotGap { height: 41px; flex-shrink: 0; }
|
|
140
227
|
|
|
141
228
|
.km .cardTop { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
|
142
229
|
.km .cardBar { display: block; height: 4px; border-radius: 1px; background: var(--c-cobalt-700); }
|
|
@@ -153,24 +240,26 @@
|
|
|
153
240
|
|
|
154
241
|
.km .mover {
|
|
155
242
|
position: absolute;
|
|
156
|
-
/* Origin: column 1's top slot (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
243
|
+
/* Origin: column 1's top slot. Exact calc() geometry — see the
|
|
244
|
+
header comment — so the card sits flush in the slot at rest and
|
|
245
|
+
when it lands, at every frame width. The lift is a transform so
|
|
246
|
+
left/top always mean "the slot". */
|
|
247
|
+
left: var(--km-x1);
|
|
248
|
+
top: var(--km-y);
|
|
249
|
+
width: var(--km-card-w);
|
|
161
250
|
animation: kmMove var(--km-dur) linear infinite;
|
|
162
251
|
z-index: 2;
|
|
163
252
|
}
|
|
164
253
|
|
|
165
254
|
@keyframes kmMove {
|
|
166
|
-
0%, 10% { left:
|
|
167
|
-
14% { left:
|
|
168
|
-
50% { left:
|
|
169
|
-
54% { left:
|
|
170
|
-
86% { left:
|
|
171
|
-
92% { left:
|
|
172
|
-
96% { left:
|
|
173
|
-
100% { left:
|
|
255
|
+
0%, 10% { left: var(--km-x1); top: var(--km-y); transform: translateY(0) scale(1); box-shadow: var(--shadow-1); opacity: 1; }
|
|
256
|
+
14% { left: var(--km-x1); top: var(--km-y); transform: translateY(-3px) scale(1.04); box-shadow: var(--shadow-3); opacity: 1; }
|
|
257
|
+
50% { left: var(--km-x2); top: var(--km-y); transform: translateY(-3px) scale(1.04); box-shadow: var(--shadow-3); opacity: 1; }
|
|
258
|
+
54% { left: var(--km-x2); top: var(--km-y); transform: translateY(0) scale(1); box-shadow: var(--shadow-1); opacity: 1; }
|
|
259
|
+
86% { left: var(--km-x2); top: var(--km-y); transform: translateY(0) scale(1); box-shadow: var(--shadow-1); opacity: 1; }
|
|
260
|
+
92% { left: var(--km-x2); top: var(--km-y); opacity: 0; }
|
|
261
|
+
96% { left: var(--km-x1); top: var(--km-y); opacity: 0; }
|
|
262
|
+
100% { left: var(--km-x1); top: var(--km-y); transform: translateY(0) scale(1); box-shadow: var(--shadow-1); opacity: 1; }
|
|
174
263
|
}
|
|
175
264
|
|
|
176
265
|
/* ---- Cursor ---- */
|
|
@@ -191,23 +280,26 @@
|
|
|
191
280
|
|
|
192
281
|
@keyframes kmCursor {
|
|
193
282
|
0% { left: 60%; top: 60%; }
|
|
194
|
-
10% { left:
|
|
195
|
-
14% { left:
|
|
196
|
-
50% { left:
|
|
197
|
-
54% { left:
|
|
283
|
+
10% { left: calc(var(--km-x1) + 10%); top: calc(var(--km-y) + 18px); } /* over the card */
|
|
284
|
+
14% { left: calc(var(--km-x1) + 10%); top: calc(var(--km-y) + 15px); }
|
|
285
|
+
50% { left: calc(var(--km-x2) + 10%); top: calc(var(--km-y) + 15px); } /* drag with the card */
|
|
286
|
+
54% { left: calc(var(--km-x2) + 10%); top: calc(var(--km-y) + 18px); }
|
|
198
287
|
64% { left: 60%; top: 60%; } /* release, drift away */
|
|
199
288
|
100% { left: 60%; top: 60%; }
|
|
200
289
|
}
|
|
201
290
|
|
|
202
291
|
/* ---- Count crossfade ---- */
|
|
203
292
|
|
|
293
|
+
/* Counts and KPI values swap the moment the card settles: the drop
|
|
294
|
+
ends at 54%, the crossfade runs 54–56%. They revert while the card
|
|
295
|
+
fades back to its origin (86–96%). */
|
|
204
296
|
@keyframes kmCountBefore {
|
|
205
|
-
0%,
|
|
297
|
+
0%, 54% { opacity: 1; }
|
|
206
298
|
56%, 88% { opacity: 0; }
|
|
207
299
|
93%, 100% { opacity: 1; }
|
|
208
300
|
}
|
|
209
301
|
@keyframes kmCountAfter {
|
|
210
|
-
0%,
|
|
302
|
+
0%, 54% { opacity: 0; }
|
|
211
303
|
56%, 88% { opacity: 1; }
|
|
212
304
|
93%, 100% { opacity: 0; }
|
|
213
305
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KanbanMock.render.test.js — renders the real <KanbanMock> JSX to
|
|
3
|
+
* static markup (the SSR path Docusaurus takes at build time) and
|
|
4
|
+
* asserts on the output: the KPI row above the lanes with its
|
|
5
|
+
* before/after € values, the column counts, and the static mode.
|
|
6
|
+
*
|
|
7
|
+
* Uses the same esbuild-bundle-then-renderToStaticMarkup technique as
|
|
8
|
+
* AiDisclosure.render.test.js / scripts/build-kit.mjs. CSS modules
|
|
9
|
+
* are stubbed with an identity proxy (styles.foo → 'foo'), so class
|
|
10
|
+
* assertions read as the source class names.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const test = require('node:test');
|
|
16
|
+
const {before, after} = test;
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const fs = require('node:fs/promises');
|
|
20
|
+
const {build} = require('esbuild');
|
|
21
|
+
const React = require('react');
|
|
22
|
+
const {renderToStaticMarkup} = require('react-dom/server');
|
|
23
|
+
|
|
24
|
+
const COMPONENT = path.resolve(__dirname, '..', 'KanbanMock.jsx');
|
|
25
|
+
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
26
|
+
const SCRATCH = path.join(PRESET_ROOT, '.tmp-kanban-mock-test');
|
|
27
|
+
|
|
28
|
+
const cssModuleStub = {
|
|
29
|
+
name: 'css-module-stub',
|
|
30
|
+
setup(b) {
|
|
31
|
+
b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
|
|
32
|
+
b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
|
|
33
|
+
contents: 'export default new Proxy({}, {get: (_, p) => p});',
|
|
34
|
+
loader: 'js',
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let KanbanMock;
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
await fs.mkdir(SCRATCH, {recursive: true});
|
|
43
|
+
const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
|
|
44
|
+
const outFile = path.join(tmpDir, 'bundle.cjs');
|
|
45
|
+
await build({
|
|
46
|
+
entryPoints: [COMPONENT],
|
|
47
|
+
outfile: outFile,
|
|
48
|
+
bundle: true,
|
|
49
|
+
format: 'cjs',
|
|
50
|
+
jsx: 'automatic',
|
|
51
|
+
jsxImportSource: 'react',
|
|
52
|
+
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
53
|
+
platform: 'node',
|
|
54
|
+
external: ['react'],
|
|
55
|
+
plugins: [cssModuleStub],
|
|
56
|
+
logLevel: 'warning',
|
|
57
|
+
});
|
|
58
|
+
delete require.cache[require.resolve(outFile)];
|
|
59
|
+
KanbanMock = require(outFile).default;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
after(async () => {
|
|
63
|
+
await fs.rm(SCRATCH, {recursive: true, force: true});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function render(props) {
|
|
67
|
+
return renderToStaticMarkup(React.createElement(KanbanMock, props));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('SSR smoke: renders the board with three lanes and the travelling card', () => {
|
|
71
|
+
const html = render({});
|
|
72
|
+
assert.equal((html.match(/class="col"/g) || []).length, 3);
|
|
73
|
+
assert.match(html, /class="mover"/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('KPI row: one € tile per lane, before/after values on the two lanes the deal moves between', () => {
|
|
77
|
+
const html = render({});
|
|
78
|
+
assert.match(html, /class="kpis"/);
|
|
79
|
+
assert.equal((html.match(/class="kpi"/g) || []).length, 3);
|
|
80
|
+
// Source lane loses the €18k deal, target lane gains it.
|
|
81
|
+
assert.match(html, /class="countBefore">€ 48k</);
|
|
82
|
+
assert.match(html, /class="countAfter">€ 30k</);
|
|
83
|
+
assert.match(html, /class="countBefore">€ 27k</);
|
|
84
|
+
assert.match(html, /class="countAfter">€ 45k</);
|
|
85
|
+
// Third lane never changes: a still value, no crossfade spans.
|
|
86
|
+
assert.match(html, /class="kpiStill">€ 64k</);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('KPI arithmetic: the moved deal value leaving lane 1 equals the value entering lane 2', () => {
|
|
90
|
+
const html = render({});
|
|
91
|
+
const nums = [...html.matchAll(/class="count(Before|After)">€ (\d+)k</g)].map((m) => Number(m[2]));
|
|
92
|
+
assert.equal(nums.length, 4, 'expected two crossfading € tiles (2 values each)');
|
|
93
|
+
// Order in markup: lane1 before, lane1 after, lane2 before, lane2 after.
|
|
94
|
+
const [l1From, l1To, l2From, l2To] = nums;
|
|
95
|
+
assert.equal(l1From - l1To, l2To - l2From, 'the deal value must shift lanes, not appear or vanish');
|
|
96
|
+
assert.ok(l1From - l1To > 0, 'source lane must decrement');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('column counts: source lane 3→2, target lane 2→3, third lane static 4', () => {
|
|
100
|
+
const html = render({});
|
|
101
|
+
assert.match(html, /class="count"><span class="countBefore">3<\/span><span class="countAfter">2<\/span>/);
|
|
102
|
+
assert.match(html, /class="count"><span class="countBefore">2<\/span><span class="countAfter">3<\/span>/);
|
|
103
|
+
assert.match(html, /class="countStatic">4</);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('aria-label names the counts and lane totals updating at the drop', () => {
|
|
107
|
+
const html = render({});
|
|
108
|
+
assert.match(html, /aria-label="[^"]*column counts and the lane value totals update at the drop[^"]*"/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('running={false} renders the static frame class', () => {
|
|
112
|
+
const html = render({running: false});
|
|
113
|
+
assert.match(html, /class="frame size-md static"/);
|
|
114
|
+
});
|
|
@@ -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
|
|
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
|
|
228
|
+
speed,
|
|
56
229
|
className,
|
|
57
230
|
}) {
|
|
58
231
|
const cols = columns || (widgets.length >= 4 ? 3 : Math.min(widgets.length, 3));
|
|
59
|
-
const
|
|
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}>
|
|
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
|
-
|
|
77
|
-
<div
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
394
|
+
{widgets.map(renderCard)}
|
|
102
395
|
</div>
|
|
103
396
|
)}
|
|
104
397
|
</section>
|
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
* duplicated for a seamless loop, paused on hover/focus) and the
|
|
8
8
|
* static responsive grid (carousel={false}, and what
|
|
9
9
|
* prefers-reduced-motion collapses the carousel to).
|
|
10
|
+
*
|
|
11
|
+
* The CSS keyframe marquee is the SSR/no-JS fallback; once the
|
|
12
|
+
* component mounts, the JSX adds .jsDriven to the track and a rAF
|
|
13
|
+
* loop drives the same transform so the prev/next/pause controls can
|
|
14
|
+
* steer it. Controls are cobalt ghost buttons (never orange fill)
|
|
15
|
+
* and are hidden under reduced motion together with the marquee.
|
|
16
|
+
*
|
|
17
|
+
* Auto panels (.auto*): token-built mini mocks for widgets without an
|
|
18
|
+
* explicit panel. Four archetypes (KPI tile, bar chart, list rows,
|
|
19
|
+
* donut gauge); the accent comes from --wsa / --wsa-soft, set inline
|
|
20
|
+
* by the JSX from a hash of the widget title.
|
|
10
21
|
*/
|
|
11
22
|
|
|
12
23
|
.shelf {
|
|
@@ -89,6 +100,56 @@
|
|
|
89
100
|
to { transform: translateX(-50%); }
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
/* Once the rAF loop owns the transform, the keyframe animation must
|
|
104
|
+
let go — otherwise it would overwrite the inline transform. */
|
|
105
|
+
.track.jsDriven { animation: none; }
|
|
106
|
+
|
|
107
|
+
/* ---- Controls (pause/play + prev/next) ----
|
|
108
|
+
*
|
|
109
|
+
* Cobalt ghost buttons per the kit: transparent ground, cobalt ink,
|
|
110
|
+
* cobalt-200 hairline; hover tints, focus ring is a visible cobalt
|
|
111
|
+
* outline. Never orange. Hidden under reduced motion because the
|
|
112
|
+
* static grid has nothing to steer. */
|
|
113
|
+
|
|
114
|
+
.controls {
|
|
115
|
+
display: flex;
|
|
116
|
+
justify-content: flex-end;
|
|
117
|
+
gap: var(--space-2);
|
|
118
|
+
margin-top: var(--space-4);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ctrl {
|
|
122
|
+
appearance: none;
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
width: 38px;
|
|
127
|
+
height: 38px;
|
|
128
|
+
padding: 0;
|
|
129
|
+
border: 1px solid var(--c-cobalt-200);
|
|
130
|
+
border-radius: var(--radius-pill);
|
|
131
|
+
background: transparent;
|
|
132
|
+
color: var(--c-blue-cobalt);
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
}
|
|
135
|
+
.ctrl:hover {
|
|
136
|
+
background: var(--c-cobalt-50);
|
|
137
|
+
border-color: var(--c-blue-cobalt);
|
|
138
|
+
}
|
|
139
|
+
.ctrl:focus-visible {
|
|
140
|
+
outline: 2px solid var(--c-blue-cobalt);
|
|
141
|
+
outline-offset: 2px;
|
|
142
|
+
}
|
|
143
|
+
.ctrl svg { width: 16px; height: 16px; display: block; }
|
|
144
|
+
.chev {
|
|
145
|
+
fill: none;
|
|
146
|
+
stroke: currentColor;
|
|
147
|
+
stroke-width: 2.4;
|
|
148
|
+
stroke-linecap: round;
|
|
149
|
+
stroke-linejoin: round;
|
|
150
|
+
}
|
|
151
|
+
.glyph { fill: currentColor; }
|
|
152
|
+
|
|
92
153
|
/* Reduced motion: drop the duplicate copy and render the first group
|
|
93
154
|
as the static wrapping grid — identical to carousel={false}. The
|
|
94
155
|
.cols-2 / .cols-3 class on the group supplies the column template. */
|
|
@@ -98,7 +159,8 @@
|
|
|
98
159
|
-webkit-mask-image: none;
|
|
99
160
|
mask-image: none;
|
|
100
161
|
}
|
|
101
|
-
.
|
|
162
|
+
.controls { display: none; }
|
|
163
|
+
.track { animation: none; display: block; width: 100%; transform: none !important; }
|
|
102
164
|
.group {
|
|
103
165
|
display: grid;
|
|
104
166
|
gap: var(--space-5);
|
|
@@ -150,3 +212,133 @@
|
|
|
150
212
|
margin: 0;
|
|
151
213
|
padding: var(--space-2) var(--space-5) var(--space-5);
|
|
152
214
|
}
|
|
215
|
+
|
|
216
|
+
/* ---- Auto-generated mini panels ----
|
|
217
|
+
*
|
|
218
|
+
* White widget tile on the tinted panel ground, same chrome family as
|
|
219
|
+
* the hand-built .w mocks. All greeked (bars, dots, one number); the
|
|
220
|
+
* accent tokens --wsa / --wsa-soft arrive inline from the JSX. */
|
|
221
|
+
|
|
222
|
+
.auto {
|
|
223
|
+
box-sizing: border-box;
|
|
224
|
+
width: 100%;
|
|
225
|
+
max-width: 280px;
|
|
226
|
+
min-height: 124px;
|
|
227
|
+
background: white;
|
|
228
|
+
border: 1px solid var(--c-cobalt-100);
|
|
229
|
+
border-radius: var(--radius-md);
|
|
230
|
+
box-shadow: var(--shadow-1);
|
|
231
|
+
padding: 16px 18px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* KPI number tile */
|
|
235
|
+
.autoKpi {
|
|
236
|
+
display: flex;
|
|
237
|
+
flex-direction: column;
|
|
238
|
+
justify-content: center;
|
|
239
|
+
gap: 10px;
|
|
240
|
+
}
|
|
241
|
+
.autoHex {
|
|
242
|
+
display: block;
|
|
243
|
+
width: 13px;
|
|
244
|
+
height: 15px;
|
|
245
|
+
clip-path: var(--hex-pointy-top);
|
|
246
|
+
background: var(--wsa);
|
|
247
|
+
}
|
|
248
|
+
.autoKpiValue {
|
|
249
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
250
|
+
font-size: 34px;
|
|
251
|
+
font-weight: 700;
|
|
252
|
+
line-height: 1;
|
|
253
|
+
letter-spacing: -0.01em;
|
|
254
|
+
color: var(--c-cobalt-900);
|
|
255
|
+
}
|
|
256
|
+
.autoKpiBar {
|
|
257
|
+
display: block;
|
|
258
|
+
height: 5px;
|
|
259
|
+
border-radius: 1.5px;
|
|
260
|
+
background: var(--wsa-soft);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Mini bar chart */
|
|
264
|
+
.autoBars {
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: flex-end;
|
|
267
|
+
gap: 8px;
|
|
268
|
+
height: 124px;
|
|
269
|
+
}
|
|
270
|
+
.autoBar {
|
|
271
|
+
flex: 1;
|
|
272
|
+
border-radius: 2px 2px 0 0;
|
|
273
|
+
background: var(--c-cobalt-200);
|
|
274
|
+
}
|
|
275
|
+
.autoBarHi { background: var(--wsa); }
|
|
276
|
+
|
|
277
|
+
/* List rows */
|
|
278
|
+
.autoList {
|
|
279
|
+
display: flex;
|
|
280
|
+
flex-direction: column;
|
|
281
|
+
justify-content: center;
|
|
282
|
+
gap: 12px;
|
|
283
|
+
}
|
|
284
|
+
.autoRow {
|
|
285
|
+
display: flex;
|
|
286
|
+
align-items: center;
|
|
287
|
+
gap: 10px;
|
|
288
|
+
}
|
|
289
|
+
.autoDot {
|
|
290
|
+
flex-shrink: 0;
|
|
291
|
+
width: 12px;
|
|
292
|
+
height: 14px;
|
|
293
|
+
clip-path: var(--hex-pointy-top);
|
|
294
|
+
background: var(--c-cobalt-200);
|
|
295
|
+
}
|
|
296
|
+
.autoDotHi { background: var(--wsa); }
|
|
297
|
+
.autoRowBars {
|
|
298
|
+
flex: 1;
|
|
299
|
+
display: flex;
|
|
300
|
+
flex-direction: column;
|
|
301
|
+
gap: 5px;
|
|
302
|
+
}
|
|
303
|
+
.autoRowBar {
|
|
304
|
+
display: block;
|
|
305
|
+
height: 5px;
|
|
306
|
+
border-radius: 1.5px;
|
|
307
|
+
background: var(--c-cobalt-700);
|
|
308
|
+
}
|
|
309
|
+
.autoRowLine {
|
|
310
|
+
display: block;
|
|
311
|
+
width: 86%;
|
|
312
|
+
height: 4px;
|
|
313
|
+
border-radius: 1px;
|
|
314
|
+
background: var(--c-cobalt-100);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* Donut gauge */
|
|
318
|
+
.autoDonut {
|
|
319
|
+
display: flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 18px;
|
|
322
|
+
}
|
|
323
|
+
.autoRing {
|
|
324
|
+
flex-shrink: 0;
|
|
325
|
+
width: 84px;
|
|
326
|
+
height: 84px;
|
|
327
|
+
}
|
|
328
|
+
.autoRingTrack {
|
|
329
|
+
fill: none;
|
|
330
|
+
stroke: var(--c-cobalt-100);
|
|
331
|
+
stroke-width: 6;
|
|
332
|
+
}
|
|
333
|
+
.autoRingFill {
|
|
334
|
+
fill: none;
|
|
335
|
+
stroke: var(--wsa);
|
|
336
|
+
stroke-width: 6;
|
|
337
|
+
stroke-linecap: round;
|
|
338
|
+
}
|
|
339
|
+
.autoDonutBars {
|
|
340
|
+
flex: 1;
|
|
341
|
+
display: flex;
|
|
342
|
+
flex-direction: column;
|
|
343
|
+
gap: 6px;
|
|
344
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WidgetShelf.render.test.js — renders the real <WidgetShelf> JSX to
|
|
3
|
+
* static markup (the SSR path Docusaurus takes at build time) and
|
|
4
|
+
* asserts on the output: derived carousel speed, auto-generated
|
|
5
|
+
* panels for widgets without an explicit `panel`, and the carousel
|
|
6
|
+
* controls with their accessible names.
|
|
7
|
+
*
|
|
8
|
+
* Uses the same esbuild-bundle-then-renderToStaticMarkup technique as
|
|
9
|
+
* AiDisclosure.render.test.js / scripts/build-kit.mjs. CSS modules
|
|
10
|
+
* are stubbed with an identity proxy (styles.foo → 'foo'), so class
|
|
11
|
+
* assertions read as the source class names.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test');
|
|
17
|
+
const {before, after} = test;
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const fs = require('node:fs/promises');
|
|
21
|
+
const {build} = require('esbuild');
|
|
22
|
+
const React = require('react');
|
|
23
|
+
const {renderToStaticMarkup} = require('react-dom/server');
|
|
24
|
+
|
|
25
|
+
const COMPONENT = path.resolve(__dirname, '..', 'WidgetShelf.jsx');
|
|
26
|
+
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
27
|
+
const SCRATCH = path.join(PRESET_ROOT, '.tmp-widget-shelf-test');
|
|
28
|
+
|
|
29
|
+
const cssModuleStub = {
|
|
30
|
+
name: 'css-module-stub',
|
|
31
|
+
setup(b) {
|
|
32
|
+
b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
|
|
33
|
+
b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
|
|
34
|
+
contents: 'export default new Proxy({}, {get: (_, p) => p});',
|
|
35
|
+
loader: 'js',
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let WidgetShelf;
|
|
41
|
+
|
|
42
|
+
before(async () => {
|
|
43
|
+
await fs.mkdir(SCRATCH, {recursive: true});
|
|
44
|
+
const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
|
|
45
|
+
const outFile = path.join(tmpDir, 'bundle.cjs');
|
|
46
|
+
await build({
|
|
47
|
+
entryPoints: [COMPONENT],
|
|
48
|
+
outfile: outFile,
|
|
49
|
+
bundle: true,
|
|
50
|
+
format: 'cjs',
|
|
51
|
+
jsx: 'automatic',
|
|
52
|
+
jsxImportSource: 'react',
|
|
53
|
+
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
54
|
+
platform: 'node',
|
|
55
|
+
external: ['react'],
|
|
56
|
+
plugins: [cssModuleStub],
|
|
57
|
+
logLevel: 'warning',
|
|
58
|
+
});
|
|
59
|
+
delete require.cache[require.resolve(outFile)];
|
|
60
|
+
WidgetShelf = require(outFile).default;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
after(async () => {
|
|
64
|
+
await fs.rm(SCRATCH, {recursive: true, force: true});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function render(props) {
|
|
68
|
+
return renderToStaticMarkup(React.createElement(WidgetShelf, props));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeWidgets(n) {
|
|
72
|
+
return Array.from({length: n}, (_, i) => ({
|
|
73
|
+
title: `Widget number ${i}`,
|
|
74
|
+
desc: `Description ${i}`,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('SSR smoke: carousel renders every widget twice (live group + aria-hidden dup)', () => {
|
|
79
|
+
const html = render({title: 'Shelf', widgets: makeWidgets(5)});
|
|
80
|
+
assert.equal((html.match(/Widget number 3/g) || []).length, 2);
|
|
81
|
+
assert.match(html, /aria-hidden="true"/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('default loop duration derives from widget count at 6s per card', () => {
|
|
85
|
+
const html = render({widgets: makeWidgets(15)});
|
|
86
|
+
assert.match(html, /--ws-speed:90s/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('default loop duration never drops below the 30s floor', () => {
|
|
90
|
+
const html = render({widgets: makeWidgets(3)});
|
|
91
|
+
assert.match(html, /--ws-speed:30s/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an explicit speed prop overrides the derived duration', () => {
|
|
95
|
+
const html = render({widgets: makeWidgets(15), speed: 45});
|
|
96
|
+
assert.match(html, /--ws-speed:45s/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a widget without a panel gets an auto-generated mini panel', () => {
|
|
100
|
+
const html = render({widgets: [{title: 'Werkvoorraad', desc: 'x'}]});
|
|
101
|
+
assert.match(html, /class="auto auto(Kpi|Bars|List|Donut)"/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('an explicit panel always wins over the auto panel', () => {
|
|
105
|
+
const html = render({
|
|
106
|
+
widgets: [{title: 'Custom', desc: 'x', panel: React.createElement('div', {className: 'w w-custom'})}],
|
|
107
|
+
});
|
|
108
|
+
assert.match(html, /w-custom/);
|
|
109
|
+
assert.doesNotMatch(html, /class="auto /);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('auto panels are deterministic: same input, same markup', () => {
|
|
113
|
+
const props = {widgets: makeWidgets(8)};
|
|
114
|
+
assert.equal(render(props), render(props));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('auto panels vary: 8 distinct titles hit at least two archetypes and two accent families', () => {
|
|
118
|
+
const html = render({widgets: makeWidgets(8)});
|
|
119
|
+
const kinds = new Set(
|
|
120
|
+
(html.match(/class="auto auto(Kpi|Bars|List|Donut)"/g) || []).map((m) => m),
|
|
121
|
+
);
|
|
122
|
+
assert.ok(kinds.size >= 2, `expected >=2 archetypes, saw ${[...kinds].join(', ')}`);
|
|
123
|
+
const families = new Set(
|
|
124
|
+
(html.match(/--wsa:var\(--c-([a-z]+)-500\)/g) || []).map((m) => m),
|
|
125
|
+
);
|
|
126
|
+
assert.ok(families.size >= 2, `expected >=2 accent families, saw ${[...families].join(', ')}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('auto panels never use reserved accent families (coral/orange, gold)', () => {
|
|
130
|
+
// 64 distinct titles sweep the family picker; the hex-family policy
|
|
131
|
+
// reserves coral (KNVB orange) and gold, so neither may ever appear.
|
|
132
|
+
const html = render({widgets: makeWidgets(64)});
|
|
133
|
+
assert.doesNotMatch(html, /--c-coral|--c-gold|--c-orange/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('carousel renders pause/play and prev/next controls with accessible names', () => {
|
|
137
|
+
const html = render({widgets: makeWidgets(4)});
|
|
138
|
+
assert.match(html, /aria-label="Previous widgets"/);
|
|
139
|
+
assert.match(html, /aria-label="Next widgets"/);
|
|
140
|
+
assert.match(html, /aria-label="Pause"/);
|
|
141
|
+
assert.match(html, /<button type="button"/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the static grid (carousel={false}) has no controls and no duplicate group', () => {
|
|
145
|
+
const html = render({widgets: makeWidgets(4), carousel: false});
|
|
146
|
+
assert.doesNotMatch(html, /aria-label="Previous widgets"/);
|
|
147
|
+
assert.doesNotMatch(html, /aria-label="Pause"/);
|
|
148
|
+
assert.equal((html.match(/Widget number 3/g) || []).length, 1);
|
|
149
|
+
});
|