@conduction/docusaurus-preset 3.31.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 +6 -2
- package/package.json +1 -1
- package/src/components/BuildMock/BuildMock.jsx +101 -0
- package/src/components/BuildMock/BuildMock.module.css +290 -0
- package/src/components/DetailHero/DetailHero.jsx +68 -16
- package/src/components/DetailHero/DetailHero.module.css +31 -1
- package/src/components/FlowMock/FlowMock.jsx +151 -0
- package/src/components/FlowMock/FlowMock.module.css +164 -0
- package/src/components/KanbanMock/KanbanMock.jsx +149 -0
- package/src/components/KanbanMock/KanbanMock.module.css +325 -0
- package/src/components/KanbanMock/__tests__/KanbanMock.render.test.js +114 -0
- package/src/components/LeafMock/LeafMock.jsx +173 -0
- package/src/components/LeafMock/LeafMock.module.css +237 -0
- package/src/components/RotatingCards/RotatingCards.module.css +5 -2
- package/src/components/Showcase/Showcase.module.css +5 -2
- package/src/components/WidgetShelf/WidgetShelf.jsx +346 -14
- package/src/components/WidgetShelf/WidgetShelf.module.css +276 -5
- package/src/components/WidgetShelf/__tests__/WidgetShelf.render.test.js +149 -0
- package/src/components/index.js +4 -0
- package/src/components/primitives/SectionHead.module.css +6 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <LeafMock /> styles. Scoped under `.lm` (display: contents), the
|
|
3
|
+
* same convention as AppMock's `.am`, so the file also works as a
|
|
4
|
+
* plain global stylesheet for static specimen pages.
|
|
5
|
+
*
|
|
6
|
+
* Tokens only. Accent discipline per leaf: contacts sync = mint,
|
|
7
|
+
* calendar event bar = lavender, mail action chip = mint, files
|
|
8
|
+
* record chip = forest. No orange anywhere in this component.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
.lm { display: contents; }
|
|
12
|
+
|
|
13
|
+
.lm .figure {
|
|
14
|
+
margin: 0;
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: var(--space-2);
|
|
18
|
+
width: 100%;
|
|
19
|
+
max-width: 100%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.lm .frame {
|
|
23
|
+
width: 100%;
|
|
24
|
+
max-width: 360px;
|
|
25
|
+
aspect-ratio: 4 / 3;
|
|
26
|
+
background: white;
|
|
27
|
+
border: 1px solid var(--c-cobalt-100);
|
|
28
|
+
border-radius: var(--radius-lg);
|
|
29
|
+
box-shadow: var(--shadow-3);
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
padding: var(--space-4);
|
|
35
|
+
font-family: var(--conduction-typography-font-family-body);
|
|
36
|
+
}
|
|
37
|
+
.lm .size-sm { max-width: 240px; }
|
|
38
|
+
.lm .size-md { max-width: 360px; }
|
|
39
|
+
|
|
40
|
+
.lm .caption {
|
|
41
|
+
font-family: var(--conduction-typography-font-family-code);
|
|
42
|
+
font-size: 11px;
|
|
43
|
+
letter-spacing: 0.06em;
|
|
44
|
+
color: var(--c-cobalt-400);
|
|
45
|
+
text-transform: uppercase;
|
|
46
|
+
text-align: center;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.lm .empty {
|
|
50
|
+
color: var(--c-cobalt-400);
|
|
51
|
+
font-size: 13px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ---- Greeked lines ---- */
|
|
55
|
+
|
|
56
|
+
.lm .greek { display: block; height: 3px; border-radius: 1px; background: var(--c-cobalt-200); }
|
|
57
|
+
.lm .greek-title { height: 4px; background: var(--c-cobalt-700); }
|
|
58
|
+
.lm .greek-chip { height: 3px; background: currentColor; opacity: 0.8; }
|
|
59
|
+
|
|
60
|
+
/* ---- Contacts leaf ---- */
|
|
61
|
+
|
|
62
|
+
.lm .contacts {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: var(--space-3);
|
|
66
|
+
width: 100%;
|
|
67
|
+
}
|
|
68
|
+
.lm .contactList {
|
|
69
|
+
flex: 1;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
.lm .contactRow { display: flex; align-items: center; gap: 8px; }
|
|
76
|
+
.lm .hexAvatar {
|
|
77
|
+
width: 20px;
|
|
78
|
+
height: 23px;
|
|
79
|
+
clip-path: var(--hex-pointy-top);
|
|
80
|
+
background: var(--c-cobalt-300);
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
}
|
|
83
|
+
.lm .hexAvatar-1 { background: var(--c-lavender-300); }
|
|
84
|
+
.lm .hexAvatar-2 { background: var(--c-cobalt-200); }
|
|
85
|
+
.lm .contactLines { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
86
|
+
|
|
87
|
+
.lm .syncArrow {
|
|
88
|
+
width: 22px;
|
|
89
|
+
height: 22px;
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
fill: none;
|
|
92
|
+
stroke: var(--c-mint-500);
|
|
93
|
+
stroke-width: 2;
|
|
94
|
+
stroke-linecap: round;
|
|
95
|
+
stroke-linejoin: round;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.lm .phone {
|
|
99
|
+
width: 44px;
|
|
100
|
+
height: 82px;
|
|
101
|
+
border: 2px solid var(--c-cobalt-300);
|
|
102
|
+
border-radius: 9px;
|
|
103
|
+
padding: 6px 5px;
|
|
104
|
+
flex-shrink: 0;
|
|
105
|
+
}
|
|
106
|
+
.lm .phoneScreen {
|
|
107
|
+
height: 100%;
|
|
108
|
+
border-radius: 4px;
|
|
109
|
+
background: var(--c-cobalt-50);
|
|
110
|
+
padding: 6px 4px;
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
gap: 5px;
|
|
114
|
+
}
|
|
115
|
+
.lm .phoneRow { display: block; height: 3px; border-radius: 1px; background: var(--c-cobalt-200); }
|
|
116
|
+
|
|
117
|
+
/* ---- Calendar leaf ---- */
|
|
118
|
+
|
|
119
|
+
.lm .calendar {
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-direction: column;
|
|
122
|
+
gap: var(--space-3);
|
|
123
|
+
width: 100%;
|
|
124
|
+
max-width: 220px;
|
|
125
|
+
}
|
|
126
|
+
.lm .monthGrid {
|
|
127
|
+
display: grid;
|
|
128
|
+
grid-template-columns: repeat(7, 1fr);
|
|
129
|
+
gap: 5px;
|
|
130
|
+
}
|
|
131
|
+
.lm .day {
|
|
132
|
+
aspect-ratio: 1;
|
|
133
|
+
border-radius: 3px;
|
|
134
|
+
background: var(--c-cobalt-50);
|
|
135
|
+
border: 1px solid var(--c-cobalt-100);
|
|
136
|
+
}
|
|
137
|
+
.lm .dayActive { background: var(--c-lavender-300); border-color: var(--c-lavender-500); }
|
|
138
|
+
|
|
139
|
+
.lm .eventChip {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
background: white;
|
|
144
|
+
border: 1px solid var(--c-cobalt-100);
|
|
145
|
+
border-radius: var(--radius-sm);
|
|
146
|
+
box-shadow: var(--shadow-1);
|
|
147
|
+
padding: 7px 9px;
|
|
148
|
+
}
|
|
149
|
+
.lm .eventBar {
|
|
150
|
+
width: 4px;
|
|
151
|
+
align-self: stretch;
|
|
152
|
+
border-radius: 2px;
|
|
153
|
+
background: var(--c-lavender-500);
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
}
|
|
156
|
+
.lm .eventLines { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
157
|
+
.lm .linkIcon {
|
|
158
|
+
width: 13px;
|
|
159
|
+
height: 13px;
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
fill: none;
|
|
162
|
+
stroke: currentColor;
|
|
163
|
+
stroke-width: 2;
|
|
164
|
+
stroke-linecap: round;
|
|
165
|
+
stroke-linejoin: round;
|
|
166
|
+
color: var(--c-cobalt-400);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ---- Mail leaf ---- */
|
|
170
|
+
|
|
171
|
+
.lm .mail {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-direction: column;
|
|
174
|
+
gap: 8px;
|
|
175
|
+
width: 100%;
|
|
176
|
+
}
|
|
177
|
+
.lm .mailRow {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: 8px;
|
|
181
|
+
padding: 7px 9px;
|
|
182
|
+
border-radius: var(--radius-sm);
|
|
183
|
+
border: 1px solid transparent;
|
|
184
|
+
}
|
|
185
|
+
.lm .mailRowActive { background: var(--c-cobalt-50); border-color: var(--c-cobalt-100); }
|
|
186
|
+
.lm .mailDot {
|
|
187
|
+
width: 16px;
|
|
188
|
+
height: 16px;
|
|
189
|
+
border-radius: 50%;
|
|
190
|
+
background: var(--c-cobalt-200);
|
|
191
|
+
flex-shrink: 0;
|
|
192
|
+
}
|
|
193
|
+
.lm .mailLines { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
194
|
+
|
|
195
|
+
.lm .actionChip {
|
|
196
|
+
display: inline-flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
gap: 5px;
|
|
199
|
+
padding: 4px 8px;
|
|
200
|
+
border-radius: var(--radius-pill);
|
|
201
|
+
background: var(--c-mint-500);
|
|
202
|
+
color: white;
|
|
203
|
+
flex-shrink: 0;
|
|
204
|
+
}
|
|
205
|
+
.lm .plusIcon {
|
|
206
|
+
width: 10px;
|
|
207
|
+
height: 10px;
|
|
208
|
+
fill: none;
|
|
209
|
+
stroke: currentColor;
|
|
210
|
+
stroke-width: 2.4;
|
|
211
|
+
stroke-linecap: round;
|
|
212
|
+
}
|
|
213
|
+
.lm .actionChip .linkIcon { color: white; width: 10px; height: 10px; }
|
|
214
|
+
|
|
215
|
+
/* ---- Files leaf ---- */
|
|
216
|
+
|
|
217
|
+
.lm .files {
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
gap: 10px;
|
|
221
|
+
width: 100%;
|
|
222
|
+
}
|
|
223
|
+
.lm .fileRow { display: flex; align-items: center; gap: 8px; }
|
|
224
|
+
.lm .fileIcon {
|
|
225
|
+
width: 16px;
|
|
226
|
+
height: 19px;
|
|
227
|
+
border-radius: 2px;
|
|
228
|
+
background: var(--c-cobalt-200);
|
|
229
|
+
flex-shrink: 0;
|
|
230
|
+
}
|
|
231
|
+
.lm .folderIcon {
|
|
232
|
+
height: 14px;
|
|
233
|
+
background: var(--c-cobalt-300);
|
|
234
|
+
border-radius: 2px 4px 2px 2px;
|
|
235
|
+
}
|
|
236
|
+
.lm .fileLines { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
237
|
+
.lm .recordChip { background: var(--c-forest-500); }
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/* ---------- Header ---------- */
|
|
19
|
-
|
|
19
|
+
/* Full-width head: only the lede keeps the 70ch reading measure, so
|
|
20
|
+
the title fills the line instead of balance-wrapping early. */
|
|
21
|
+
.head { margin: 0 0 var(--space-8); }
|
|
20
22
|
.eyebrow {
|
|
21
23
|
display: inline-flex; align-items: center; gap: var(--space-2);
|
|
22
24
|
font-family: var(--conduction-typography-font-family-code);
|
|
@@ -28,8 +30,9 @@
|
|
|
28
30
|
.title {
|
|
29
31
|
font-size: 40px; font-weight: 700; letter-spacing: -0.02em;
|
|
30
32
|
line-height: 1.1; color: var(--c-cobalt-900); margin: 0 0 var(--space-3);
|
|
33
|
+
text-wrap: pretty; /* run full width; no artificial two-line balance */
|
|
31
34
|
}
|
|
32
|
-
.lede { font-size: 17px; line-height: 1.5; color: var(--c-cobalt-700); margin: 0; }
|
|
35
|
+
.lede { font-size: 17px; line-height: 1.5; color: var(--c-cobalt-700); margin: 0; max-width: 70ch; }
|
|
33
36
|
|
|
34
37
|
/* ---------- The stack ---------- */
|
|
35
38
|
.stack {
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/* ---------- Header ---------- */
|
|
15
|
-
|
|
15
|
+
/* Full-width head: only the lede keeps the 70ch reading measure, so
|
|
16
|
+
the title fills the line instead of balance-wrapping early. */
|
|
17
|
+
.head { margin: 0 0 var(--space-8); }
|
|
16
18
|
.eyebrow {
|
|
17
19
|
display: inline-flex; align-items: center; gap: var(--space-2);
|
|
18
20
|
font-family: var(--conduction-typography-font-family-code);
|
|
@@ -24,8 +26,9 @@
|
|
|
24
26
|
.title {
|
|
25
27
|
font-size: 40px; font-weight: 700; letter-spacing: -0.02em;
|
|
26
28
|
line-height: 1.1; color: var(--c-cobalt-900); margin: 0 0 var(--space-3);
|
|
29
|
+
text-wrap: pretty; /* run full width; no artificial two-line balance */
|
|
27
30
|
}
|
|
28
|
-
.lede { font-size: 17px; line-height: 1.5; color: var(--c-cobalt-700); margin: 0; }
|
|
31
|
+
.lede { font-size: 17px; line-height: 1.5; color: var(--c-cobalt-700); margin: 0; max-width: 70ch; }
|
|
29
32
|
|
|
30
33
|
/* ---------- Body ---------- */
|
|
31
34
|
.body { display: grid; gap: var(--space-8); align-items: stretch; }
|
|
@@ -27,25 +27,314 @@
|
|
|
27
27
|
* ]}
|
|
28
28
|
* />
|
|
29
29
|
*
|
|
30
|
-
* Layout:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Layout: by default a single-row carousel that auto-scrolls slowly
|
|
31
|
+
* (marquee-style, seamless loop via a duplicated track), pausing on
|
|
32
|
+
* hover and on keyboard focus. The duplicate copy is aria-hidden so
|
|
33
|
+
* assistive tech reads each widget once; the first copy keeps normal
|
|
34
|
+
* semantics. `prefers-reduced-motion: reduce` collapses the carousel
|
|
35
|
+
* to the static wrapping grid, which is also what `carousel={false}`
|
|
36
|
+
* renders: 2 or 3 columns depending on widget count, responsive to
|
|
37
|
+
* viewport. Each card has the panel (preview) at the top, title +
|
|
38
|
+
* description below.
|
|
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
|
+
*
|
|
55
|
+
* Props (beyond eyebrow/title/lede/widgets/columns/className):
|
|
56
|
+
* - carousel: boolean (default true) — false renders the static grid
|
|
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.
|
|
33
61
|
*/
|
|
34
62
|
|
|
35
|
-
import React from 'react';
|
|
63
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
36
64
|
import styles from './WidgetShelf.module.css';
|
|
37
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
|
+
|
|
38
221
|
export default function WidgetShelf({
|
|
39
222
|
eyebrow,
|
|
40
223
|
title,
|
|
41
224
|
lede,
|
|
42
225
|
widgets = [],
|
|
43
226
|
columns,
|
|
227
|
+
carousel = true,
|
|
228
|
+
speed,
|
|
44
229
|
className,
|
|
45
230
|
}) {
|
|
46
231
|
const cols = columns || (widgets.length >= 4 ? 3 : Math.min(widgets.length, 3));
|
|
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) => (
|
|
327
|
+
<article key={i} className={styles.card}>
|
|
328
|
+
<div className={styles.panel}>
|
|
329
|
+
{w.panel || <AutoPanel seed={w.title ? String(w.title) : `widget-${i}`} />}
|
|
330
|
+
</div>
|
|
331
|
+
{w.title && <h3 className={styles.cardTitle}>{w.title}</h3>}
|
|
332
|
+
{w.desc && <p className={styles.cardDesc}>{w.desc}</p>}
|
|
333
|
+
</article>
|
|
334
|
+
);
|
|
335
|
+
|
|
47
336
|
return (
|
|
48
|
-
<section className={[styles.shelf, className].filter(Boolean).join(' ')}>
|
|
337
|
+
<section className={[styles.shelf, carousel && styles.carousel, className].filter(Boolean).join(' ')}>
|
|
49
338
|
{(eyebrow || title || lede) && (
|
|
50
339
|
<header className={styles.head}>
|
|
51
340
|
{eyebrow && <div className={styles.eyebrow}><span className={styles.h}></span>{eyebrow}</div>}
|
|
@@ -53,15 +342,58 @@ export default function WidgetShelf({
|
|
|
53
342
|
{lede && <p className={styles.lede}>{lede}</p>}
|
|
54
343
|
</header>
|
|
55
344
|
)}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
<div
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
345
|
+
{carousel ? (
|
|
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>
|
|
363
|
+
</div>
|
|
364
|
+
</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
|
+
</>
|
|
392
|
+
) : (
|
|
393
|
+
<div className={[styles.grid, styles[`cols-${cols}`]].join(' ')}>
|
|
394
|
+
{widgets.map(renderCard)}
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
65
397
|
</section>
|
|
66
398
|
);
|
|
67
399
|
}
|