@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.
- package/MISSING_COMPONENTS.md +7 -6
- package/package.json +1 -1
- package/src/components/AgentTrace/AgentTrace.jsx +26 -5
- package/src/components/AgentTrace/AgentTrace.module.css +41 -2
- package/src/components/AgentTrace/__tests__/AgentTrace.render.test.js +108 -0
- package/src/components/AppMock/AppMock.jsx +14 -5
- package/src/components/AppMock/AppMock.module.css +439 -0
- package/src/components/AppMock/__tests__/AppMock.render.test.js +181 -0
- package/src/components/AppMock/variants/LarpingAppMock.jsx +23 -4
- package/src/components/AppMock/variants/LaunchPadBiMock.jsx +12 -5
- package/src/components/AppMock/variants/LaunchPadTilesMock.jsx +6 -1
- package/src/components/AppMock/variants/LaunchPadWidgetsMock.jsx +17 -8
- package/src/components/AppMock/variants/NLDesignMock.jsx +15 -4
- package/src/components/AppMock/variants/PipelinQMock.jsx +12 -2
- package/src/components/AppMock/variants/PortaliqMock.jsx +21 -9
- package/src/components/AppMock/variants/ScholiqMock.jsx +12 -6
- package/src/components/FlowMock/FlowMock.jsx +5 -3
- package/src/components/FlowMock/FlowMock.module.css +36 -9
- package/src/components/FlowMock/__tests__/FlowMock.render.test.js +83 -0
- 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/LeafMock/LeafMock.jsx +23 -8
- package/src/components/LeafMock/LeafMock.module.css +123 -0
- package/src/components/LeafMock/__tests__/LeafMock.render.test.js +105 -0
- package/src/components/MockScene/MockScene.jsx +16 -2
- package/src/components/MockScene/MockScene.module.css +22 -2
- package/src/components/MockScene/__tests__/MockScene.render.test.js +101 -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
|
@@ -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
|
+
});
|
|
@@ -8,26 +8,39 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Same abstraction level as <AppMock> (honeycomb.io/technologies/*):
|
|
10
10
|
* greeked content, tokens only, no real text beyond short labels.
|
|
11
|
-
* Four variants
|
|
11
|
+
* Four variants, each with a small looping animation on the shared
|
|
12
|
+
* 6s timeline (--lm-dur):
|
|
12
13
|
*
|
|
13
14
|
* - contacts: address-book rows with pointy-top hex avatars and a
|
|
14
|
-
* mint sync arrow to a phone silhouette
|
|
15
|
+
* mint sync arrow to a phone silhouette. The arrow pulses and the
|
|
16
|
+
* phone rows arrive in a stagger (the sync lands on the phone)
|
|
15
17
|
* - calendar: mini month grid with one highlighted day and a
|
|
16
|
-
* linked-event chip (lavender bar)
|
|
18
|
+
* linked-event chip (lavender bar). The day pops highlighted,
|
|
19
|
+
* then the event chip slides in beneath
|
|
17
20
|
* - mail: message list with one highlighted row carrying a
|
|
18
|
-
* mint "create from email" action chip
|
|
19
|
-
*
|
|
21
|
+
* mint "create from email" action chip. The row highlights, then
|
|
22
|
+
* the chip pops with an overshoot
|
|
23
|
+
* - files: folder/file rows with a link-to-record chip (forest).
|
|
24
|
+
* The linked row slides in, the chip pops, and its link icon
|
|
25
|
+
* draws itself (pathLength dash technique)
|
|
26
|
+
*
|
|
27
|
+
* `running={false}` (the `.static` frame class) and the user's
|
|
28
|
+
* prefers-reduced-motion setting freeze each leaf to its meaningful
|
|
29
|
+
* end state: synced rows, highlighted day + chip, highlighted mail
|
|
30
|
+
* row + chip, linked file row.
|
|
20
31
|
*
|
|
21
32
|
* Usage:
|
|
22
33
|
*
|
|
23
34
|
* <LeafMock leaf="contacts" />
|
|
24
35
|
* <LeafMock leaf="calendar" caption size="sm" />
|
|
36
|
+
* <LeafMock leaf="mail" running={false} />
|
|
25
37
|
*
|
|
26
38
|
* Props:
|
|
27
39
|
* - leaf: 'contacts' | 'calendar' | 'mail' | 'files' (required)
|
|
28
40
|
* - size: 'sm' | 'md' (default) — frame width
|
|
29
41
|
* - caption: string | true — small label below the frame;
|
|
30
42
|
* `true` uses the variant's default label
|
|
43
|
+
* - running: boolean (default true) — false freezes the leaf
|
|
31
44
|
* - className: string
|
|
32
45
|
*/
|
|
33
46
|
|
|
@@ -126,7 +139,9 @@ function FilesLeaf() {
|
|
|
126
139
|
{i === 1 && (
|
|
127
140
|
<span className={[styles.actionChip, styles.recordChip].join(' ')}>
|
|
128
141
|
<svg className={styles.linkIcon} viewBox="0 0 24 24" aria-hidden="true">
|
|
129
|
-
|
|
142
|
+
{/* pathLength="1" so the draw animation's dash
|
|
143
|
+
arithmetic is geometry-independent. */}
|
|
144
|
+
<path pathLength="1" d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6L12.5 17.5" />
|
|
130
145
|
</svg>
|
|
131
146
|
<Greek w="26px" tone="chip" />
|
|
132
147
|
</span>
|
|
@@ -144,7 +159,7 @@ const VARIANTS = {
|
|
|
144
159
|
files: {Component: FilesLeaf, label: 'Files leaf'},
|
|
145
160
|
};
|
|
146
161
|
|
|
147
|
-
export default function LeafMock({leaf, size = 'md', caption, className}) {
|
|
162
|
+
export default function LeafMock({leaf, size = 'md', caption, running = true, className}) {
|
|
148
163
|
const variant = VARIANTS[leaf];
|
|
149
164
|
if (!variant) {
|
|
150
165
|
return (
|
|
@@ -159,7 +174,7 @@ export default function LeafMock({leaf, size = 'md', caption, className}) {
|
|
|
159
174
|
return (
|
|
160
175
|
<div className={styles.lm}>
|
|
161
176
|
<figure className={[styles.figure, className].filter(Boolean).join(' ')}>
|
|
162
|
-
<div className={[styles.frame, styles[`size-${size}`]].filter(Boolean).join(' ')}>
|
|
177
|
+
<div className={[styles.frame, styles[`size-${size}`], !running && styles.static].filter(Boolean).join(' ')}>
|
|
163
178
|
<Component />
|
|
164
179
|
</div>
|
|
165
180
|
{caption && (
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* Tokens only. Accent discipline per leaf: contacts sync = mint,
|
|
7
7
|
* calendar event bar = lavender, mail action chip = mint, files
|
|
8
8
|
* record chip = forest. No orange anywhere in this component.
|
|
9
|
+
*
|
|
10
|
+
* All four leaves animate on the shared --lm-dur timeline (6s),
|
|
11
|
+
* KanbanMock conventions: base styles are the meaningful end state,
|
|
12
|
+
* loops reset by opacity swap, `.static` (running={false}) and
|
|
13
|
+
* prefers-reduced-motion freeze to the base styles.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
.lm { display: contents; }
|
|
@@ -20,6 +25,7 @@
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
.lm .frame {
|
|
28
|
+
--lm-dur: 6s;
|
|
23
29
|
width: 100%;
|
|
24
30
|
max-width: 360px;
|
|
25
31
|
aspect-ratio: 4 / 3;
|
|
@@ -235,3 +241,120 @@
|
|
|
235
241
|
}
|
|
236
242
|
.lm .fileLines { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
|
237
243
|
.lm .recordChip { background: var(--c-forest-500); }
|
|
244
|
+
|
|
245
|
+
/* ======================================================================
|
|
246
|
+
Animations — one shared 6s timeline (--lm-dur) per leaf
|
|
247
|
+
====================================================================== */
|
|
248
|
+
|
|
249
|
+
/* ---- Contacts: the sync arrow double-pulses, then the contact data
|
|
250
|
+
lands on the phone as three staggered rows. ---- */
|
|
251
|
+
|
|
252
|
+
.lm .syncArrow { animation: lmSyncPulse var(--lm-dur) ease-in-out infinite; }
|
|
253
|
+
@keyframes lmSyncPulse {
|
|
254
|
+
0%, 6% { transform: scale(1); }
|
|
255
|
+
12% { transform: scale(1.18); }
|
|
256
|
+
18% { transform: scale(1); }
|
|
257
|
+
26% { transform: scale(1.18); }
|
|
258
|
+
32%, 100% { transform: scale(1); }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.lm .phoneRow { animation: lmPhoneRow var(--lm-dur) ease-out infinite both; }
|
|
262
|
+
.lm .phoneRow:nth-child(2) { animation-delay: 0.35s; }
|
|
263
|
+
.lm .phoneRow:nth-child(3) { animation-delay: 0.7s; }
|
|
264
|
+
@keyframes lmPhoneRow {
|
|
265
|
+
0%, 18% { opacity: 0; transform: translateX(-4px); }
|
|
266
|
+
26%, 92% { opacity: 1; transform: none; }
|
|
267
|
+
97%, 100% { opacity: 0; transform: none; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ---- Calendar: the linked day pops highlighted, then the event chip
|
|
271
|
+
slides in beneath the month grid. ---- */
|
|
272
|
+
|
|
273
|
+
.lm .dayActive { animation: lmDayPop var(--lm-dur) ease-in-out infinite; }
|
|
274
|
+
@keyframes lmDayPop {
|
|
275
|
+
0%, 8% { background-color: var(--c-cobalt-50); border-color: var(--c-cobalt-100); transform: scale(1); }
|
|
276
|
+
16% { background-color: var(--c-lavender-300); border-color: var(--c-lavender-500); transform: scale(1.35); }
|
|
277
|
+
22%, 90% { background-color: var(--c-lavender-300); border-color: var(--c-lavender-500); transform: scale(1); }
|
|
278
|
+
98%, 100% { background-color: var(--c-cobalt-50); border-color: var(--c-cobalt-100); transform: scale(1); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.lm .eventChip { animation: lmChipSlide var(--lm-dur) ease-out infinite both; }
|
|
282
|
+
@keyframes lmChipSlide {
|
|
283
|
+
0%, 22% { opacity: 0; transform: translateY(8px); }
|
|
284
|
+
32%, 92% { opacity: 1; transform: none; }
|
|
285
|
+
98%, 100% { opacity: 0; transform: none; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---- Mail: the middle row highlights, then the create-from-email
|
|
289
|
+
chip pops with an overshoot. ---- */
|
|
290
|
+
|
|
291
|
+
.lm .mailRowActive { animation: lmRowHl var(--lm-dur) ease-in-out infinite; }
|
|
292
|
+
@keyframes lmRowHl {
|
|
293
|
+
0%, 10% { background-color: transparent; border-color: transparent; }
|
|
294
|
+
20%, 90% { background-color: var(--c-cobalt-50); border-color: var(--c-cobalt-100); }
|
|
295
|
+
98%, 100% { background-color: transparent; border-color: transparent; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.lm .mail .actionChip { transform-origin: center; animation: lmChipPop var(--lm-dur) ease-in-out infinite both; }
|
|
299
|
+
@keyframes lmChipPop {
|
|
300
|
+
0%, 26% { opacity: 0; transform: scale(0); }
|
|
301
|
+
34% { opacity: 1; transform: scale(1.18); }
|
|
302
|
+
39%, 90% { opacity: 1; transform: scale(1); }
|
|
303
|
+
97%, 100% { opacity: 0; transform: scale(1); }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ---- Files: the linked row slides in, the record chip pops, and its
|
|
307
|
+
link icon draws itself (the path carries pathLength="1"). ---- */
|
|
308
|
+
|
|
309
|
+
.lm .files .fileRow:nth-child(2) { animation: lmFileRow var(--lm-dur) ease-out infinite both; }
|
|
310
|
+
@keyframes lmFileRow {
|
|
311
|
+
0%, 8% { opacity: 0; transform: translateY(-4px); }
|
|
312
|
+
16%, 92% { opacity: 1; transform: none; }
|
|
313
|
+
98%, 100% { opacity: 0; transform: none; }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.lm .files .recordChip { transform-origin: center; animation: lmChipPop var(--lm-dur) ease-in-out infinite both; }
|
|
317
|
+
.lm .files .recordChip .linkIcon path {
|
|
318
|
+
stroke-dasharray: 1;
|
|
319
|
+
stroke-dashoffset: 1;
|
|
320
|
+
animation: lmLinkDraw var(--lm-dur) ease-in-out infinite both;
|
|
321
|
+
}
|
|
322
|
+
@keyframes lmLinkDraw {
|
|
323
|
+
0%, 34% { stroke-dashoffset: 1; }
|
|
324
|
+
56%, 100% { stroke-dashoffset: 0; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ======================================================================
|
|
328
|
+
Static frames — running={false} adds `.static` to `.lm .frame`; the
|
|
329
|
+
media query mirrors it for prefers-reduced-motion. Base styles are
|
|
330
|
+
the end state, so freezing is animation: none plus the icon-draw
|
|
331
|
+
dash reset.
|
|
332
|
+
====================================================================== */
|
|
333
|
+
|
|
334
|
+
.lm .static .syncArrow,
|
|
335
|
+
.lm .static .phoneRow,
|
|
336
|
+
.lm .static .dayActive,
|
|
337
|
+
.lm .static .eventChip,
|
|
338
|
+
.lm .static .mailRowActive,
|
|
339
|
+
.lm .static .mail .actionChip,
|
|
340
|
+
.lm .static .files .fileRow:nth-child(2),
|
|
341
|
+
.lm .static .files .recordChip,
|
|
342
|
+
.lm .static .files .recordChip .linkIcon path {
|
|
343
|
+
animation: none;
|
|
344
|
+
}
|
|
345
|
+
.lm .static .files .recordChip .linkIcon path { stroke-dasharray: none; stroke-dashoffset: 0; }
|
|
346
|
+
|
|
347
|
+
@media (prefers-reduced-motion: reduce) {
|
|
348
|
+
.lm .syncArrow,
|
|
349
|
+
.lm .phoneRow,
|
|
350
|
+
.lm .dayActive,
|
|
351
|
+
.lm .eventChip,
|
|
352
|
+
.lm .mailRowActive,
|
|
353
|
+
.lm .mail .actionChip,
|
|
354
|
+
.lm .files .fileRow:nth-child(2),
|
|
355
|
+
.lm .files .recordChip,
|
|
356
|
+
.lm .files .recordChip .linkIcon path {
|
|
357
|
+
animation: none;
|
|
358
|
+
}
|
|
359
|
+
.lm .files .recordChip .linkIcon path { stroke-dasharray: none; stroke-dashoffset: 0; }
|
|
360
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LeafMock.render.test.js — SSR-renders the real <LeafMock> for each
|
|
3
|
+
* of its four leaves and asserts the animation hooks and the P0
|
|
4
|
+
* `running` prop plumbing (static class on the frame). Same
|
|
5
|
+
* esbuild-bundle-then-renderToStaticMarkup harness as
|
|
6
|
+
* KanbanMock.render.test.js; CSS modules stubbed to identity.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const test = require('node:test');
|
|
12
|
+
const {before, after} = test;
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const fs = require('node:fs/promises');
|
|
16
|
+
const {build} = require('esbuild');
|
|
17
|
+
const React = require('react');
|
|
18
|
+
const {renderToStaticMarkup} = require('react-dom/server');
|
|
19
|
+
|
|
20
|
+
const COMPONENT = path.resolve(__dirname, '..', 'LeafMock.jsx');
|
|
21
|
+
const PRESET_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
22
|
+
const SCRATCH = path.join(PRESET_ROOT, '.tmp-leaf-mock-test');
|
|
23
|
+
|
|
24
|
+
const LEAVES = ['contacts', 'calendar', 'mail', 'files'];
|
|
25
|
+
|
|
26
|
+
const cssModuleStub = {
|
|
27
|
+
name: 'css-module-stub',
|
|
28
|
+
setup(b) {
|
|
29
|
+
b.onResolve({filter: /\.module\.css$/}, (args) => ({path: args.path, namespace: 'css-stub'}));
|
|
30
|
+
b.onLoad({filter: /.*/, namespace: 'css-stub'}, () => ({
|
|
31
|
+
contents: 'export default new Proxy({}, {get: (_, p) => p});',
|
|
32
|
+
loader: 'js',
|
|
33
|
+
}));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let LeafMock;
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
await fs.mkdir(SCRATCH, {recursive: true});
|
|
41
|
+
const tmpDir = await fs.mkdtemp(path.join(SCRATCH, 'run-'));
|
|
42
|
+
const outFile = path.join(tmpDir, 'bundle.cjs');
|
|
43
|
+
await build({
|
|
44
|
+
entryPoints: [COMPONENT],
|
|
45
|
+
outfile: outFile,
|
|
46
|
+
bundle: true,
|
|
47
|
+
format: 'cjs',
|
|
48
|
+
jsx: 'automatic',
|
|
49
|
+
jsxImportSource: 'react',
|
|
50
|
+
tsconfigRaw: {compilerOptions: {jsx: 'react-jsx', jsxImportSource: 'react'}},
|
|
51
|
+
platform: 'node',
|
|
52
|
+
external: ['react'],
|
|
53
|
+
plugins: [cssModuleStub],
|
|
54
|
+
logLevel: 'warning',
|
|
55
|
+
});
|
|
56
|
+
delete require.cache[require.resolve(outFile)];
|
|
57
|
+
LeafMock = require(outFile).default;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
after(async () => {
|
|
61
|
+
await fs.rm(SCRATCH, {recursive: true, force: true});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function render(props) {
|
|
65
|
+
return renderToStaticMarkup(React.createElement(LeafMock, props));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('SSR smoke: every leaf renders a frame, none falls through to Unknown leaf', () => {
|
|
69
|
+
for (const leaf of LEAVES) {
|
|
70
|
+
const html = render({leaf});
|
|
71
|
+
assert.match(html, /class="frame size-md"/, `${leaf}: frame missing`);
|
|
72
|
+
assert.doesNotMatch(html, /Unknown leaf/, `${leaf}: fell through`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('P0 plumbing: running={false} adds the static class on every leaf; default omits it', () => {
|
|
77
|
+
for (const leaf of LEAVES) {
|
|
78
|
+
assert.match(render({leaf, running: false}), /class="frame size-md static"/, `${leaf}: no static frame`);
|
|
79
|
+
assert.doesNotMatch(render({leaf}), /\bstatic\b/, `${leaf}: static leaked into the running frame`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('contacts: sync arrow and three phone rows (the staggered landing targets)', () => {
|
|
84
|
+
const html = render({leaf: 'contacts'});
|
|
85
|
+
assert.match(html, /class="syncArrow"/);
|
|
86
|
+
assert.equal((html.match(/class="phoneRow"/g) || []).length, 3);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('calendar: active day and the sliding event chip', () => {
|
|
90
|
+
const html = render({leaf: 'calendar'});
|
|
91
|
+
assert.match(html, /day dayActive/);
|
|
92
|
+
assert.match(html, /class="eventChip"/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('mail: highlighted row carries the popping action chip', () => {
|
|
96
|
+
const html = render({leaf: 'mail'});
|
|
97
|
+
assert.match(html, /mailRow mailRowActive/);
|
|
98
|
+
assert.match(html, /class="actionChip"/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('files: the record chip link icon carries pathLength="1" for the draw animation', () => {
|
|
102
|
+
const html = render({leaf: 'files'});
|
|
103
|
+
assert.match(html, /actionChip recordChip/);
|
|
104
|
+
assert.match(html, /pathLength="1"/);
|
|
105
|
+
});
|