@asteby/metacore-runtime-react 27.3.0 → 27.3.2
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/CHANGELOG.md +34 -0
- package/dist/dashboard-empty-mockup.d.ts +34 -2
- package/dist/dashboard-empty-mockup.d.ts.map +1 -1
- package/dist/dashboard-empty-mockup.js +119 -51
- package/dist/dashboard-grid.d.ts.map +1 -1
- package/dist/dashboard-grid.js +13 -2
- package/package.json +1 -1
- package/src/__tests__/dashboard-empty-mockup.test.ts +83 -0
- package/src/__tests__/dashboard-grid.test.tsx +3 -0
- package/src/dashboard-empty-mockup.tsx +211 -84
- package/src/dashboard-grid.tsx +12 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 27.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c58b455: Empty state del Tablero: el mockup animado ahora reorganiza toda la grilla en
|
|
8
|
+
loop, pasando por cuatro composiciones de dashboard (A→B→C→D→A) — las skeleton
|
|
9
|
+
cards crecen, se achican y se redistribuyen como widgets reales probando
|
|
10
|
+
acomodos mientras carga. ~2s de reposo por layout y ~1.2s de transición fluida
|
|
11
|
+
sincronizada (todas las cards transicionan a la vez, mismo easing).
|
|
12
|
+
|
|
13
|
+
Cero solapes garantizado por construcción: la grilla son 3 columnas en orden
|
|
14
|
+
fijo × top/bottom, y cada layout solo cambia anchos de columna y el split
|
|
15
|
+
top/bottom, así que todo par de tiles conserva un eje de separación consistente
|
|
16
|
+
con un `gap` constante — bajo interpolación lineal eso jamás se cruza. Se agrega
|
|
17
|
+
un test que interpola los rects (t=0..1, paso 0.05) en cada transición y asserta
|
|
18
|
+
0 solapes (estático + en tránsito). Se mantiene tokens de tema, skeleton interno
|
|
19
|
+
tipo widget card, `prefers-reduced-motion: reduce` → grilla estática (layout A) y
|
|
20
|
+
`aria-hidden`.
|
|
21
|
+
|
|
22
|
+
## 27.3.1
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- ccfb7b0: Empty state del Tablero: el mockup animado ahora es full-bleed, se mueve más y
|
|
27
|
+
va sin texto. Antes se percibía como una ilustración centrada casi estática con
|
|
28
|
+
un caption en inglés. Ahora los tiles skeleton llenan todo el área del
|
|
29
|
+
dashboard en una grilla 4×4 y se reacomodan de forma visible —pares que se
|
|
30
|
+
intercambian de lugar (horizontal, vertical y diagonal) con translate suave,
|
|
31
|
+
escalonados para que siempre haya 2-3 piezas en movimiento— como si el tablero
|
|
32
|
+
se estuviera armando solo. Se eliminó el caption ("Your dashboard is taking
|
|
33
|
+
shape"): la animación habla sola y así no se envía copy sin localizar. Se
|
|
34
|
+
mantiene el loop lento (~13s), tokens de tema y `prefers-reduced-motion: reduce`
|
|
35
|
+
(congela todo). El mock sigue decorativo (`aria-hidden`).
|
|
36
|
+
|
|
3
37
|
## 27.3.0
|
|
4
38
|
|
|
5
39
|
### Minor Changes
|
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
/** A tile's rectangle in percent of the container (both axes 0–100). */
|
|
3
|
+
export interface MockupRect {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
w: number;
|
|
7
|
+
h: number;
|
|
8
|
+
}
|
|
9
|
+
interface LayoutSpec {
|
|
10
|
+
widths: [number, number, number];
|
|
11
|
+
splits: [number, number, number];
|
|
12
|
+
}
|
|
13
|
+
export declare const MOCKUP_LAYOUTS: LayoutSpec[];
|
|
14
|
+
/** Horizontal/vertical gap between tiles, in percent. */
|
|
15
|
+
export declare const MOCKUP_GAP = 3;
|
|
2
16
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
17
|
+
* Computes the six tile rects for one layout. Tile order is
|
|
18
|
+
* [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom].
|
|
19
|
+
*
|
|
20
|
+
* Columns keep a constant `gap` between them and top/bottom keep a constant
|
|
21
|
+
* `gap` between them — this is what makes every transition overlap-free.
|
|
22
|
+
*/
|
|
23
|
+
export declare function computeLayoutRects(layout: LayoutSpec, gap?: number): MockupRect[];
|
|
24
|
+
/** The precomputed rects for all four layouts (exported for the overlap test). */
|
|
25
|
+
export declare function mockupLayoutRects(gap?: number): MockupRect[][];
|
|
26
|
+
/**
|
|
27
|
+
* Full-bleed skeleton dashboard that reflows through four layouts on a loop.
|
|
28
|
+
* Overlap-free by construction (see the invariant note above). Purely
|
|
29
|
+
* decorative — always `aria-hidden`, no text.
|
|
30
|
+
*
|
|
31
|
+
* Contract: tiles are positioned with percentage heights, so the CONTAINER
|
|
32
|
+
* must have a DEFINITE height (e.g. `h-[…]`, or a flex child with `flex-1`).
|
|
33
|
+
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
34
|
+
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
35
|
+
* definite-height box for exactly this reason.
|
|
5
36
|
*/
|
|
6
37
|
export declare function DashboardEmptyMockup({ className }: {
|
|
7
38
|
className?: string;
|
|
8
39
|
}): React.JSX.Element;
|
|
40
|
+
export {};
|
|
9
41
|
//# sourceMappingURL=dashboard-empty-mockup.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-empty-mockup.d.ts","sourceRoot":"","sources":["../src/dashboard-empty-mockup.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dashboard-empty-mockup.d.ts","sourceRoot":"","sources":["../src/dashboard-empty-mockup.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAK9B,wEAAwE;AACxE,MAAM,WAAW,UAAU;IACvB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACZ;AAKD,UAAU,UAAU;IAChB,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC;AAID,eAAO,MAAM,cAAc,EAAE,UAAU,EAKtC,CAAA;AAED,yDAAyD;AACzD,eAAO,MAAM,UAAU,IAAI,CAAA;AAE3B;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,SAAa,GAAG,UAAU,EAAE,CAerF;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,GAAG,SAAa,GAAG,UAAU,EAAE,EAAE,CAElE;AA4HD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAuBzE"}
|
|
@@ -1,59 +1,104 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// DashboardEmptyMockup — the animated empty state for the modular dashboard.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The whole grid REORGANIZES on a loop: skeleton widget cards grow, shrink and
|
|
5
|
+
// reflow through four dashboard compositions (A → B → C → D → A), as if the
|
|
6
|
+
// board were trying out arrangements while it loads. No "empty" copy — the
|
|
7
|
+
// motion carries the meaning.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// - Theme tokens only (bg-muted / border tokens) → light & dark for free.
|
|
13
|
-
// - `prefers-reduced-motion: reduce` freezes everything (tiles rest in place).
|
|
14
|
-
// - The whole mock is decorative → aria-hidden; the copy below carries the
|
|
15
|
-
// accessible meaning.
|
|
9
|
+
// ── The zero-overlap invariant (hard requirement) ──────────────────────────
|
|
10
|
+
// Two earlier takes let tiles cross and overlap; the user rejected them. This
|
|
11
|
+
// version makes overlap IMPOSSIBLE by construction, not by tuning:
|
|
16
12
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
13
|
+
// The board is 3 COLUMNS in a FIXED left-to-right order, each split into a
|
|
14
|
+
// TOP and BOTTOM tile — 6 tiles total. Every layout only changes the column
|
|
15
|
+
// WIDTHS and the per-column top/bottom SPLIT. Because column order never
|
|
16
|
+
// changes and top is always above bottom, EVERY pair of tiles keeps a
|
|
17
|
+
// consistent separating axis in every layout, with a constant `gap` between
|
|
18
|
+
// neighbours. Under linear interpolation a gap that is `gap` at both ends of
|
|
19
|
+
// a transition stays exactly `gap` throughout — so no two tiles can ever
|
|
20
|
+
// touch, at any frame, during any transition. (Verified independently by the
|
|
21
|
+
// interpolation check in dashboard-empty-mockup.test — 0 overlaps.)
|
|
22
|
+
//
|
|
23
|
+
// Design constraints kept from the spec:
|
|
24
|
+
// - Full-bleed (fills the whole dashboard area).
|
|
25
|
+
// - Pure CSS keyframes, no dependencies; injected once per document.
|
|
26
|
+
// - Synchronised reflow: all tiles share one duration / easing / timing, so
|
|
27
|
+
// A→B reads as a single reorganisation. ~2s rest per layout, ~1.2s glide.
|
|
28
|
+
// - Theme tokens only (bg-card / border / muted) → light & dark for free.
|
|
29
|
+
// - `prefers-reduced-motion: reduce` → the static A layout, no motion.
|
|
30
|
+
// - Decorative → aria-hidden, no text.
|
|
22
31
|
import * as React from 'react';
|
|
23
32
|
import { cn } from '@asteby/metacore-ui/lib';
|
|
24
|
-
// Scoped, collision-proof keyframe + class names (prefixed, unlikely to clash).
|
|
25
33
|
const STYLE_ID = 'mc-dashboard-empty-mockup-style';
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
34
|
+
// Four visibly different compositions: balanced → left-heavy → centre-heavy →
|
|
35
|
+
// right-heavy. The loop returns to A, so it is seamless.
|
|
36
|
+
export const MOCKUP_LAYOUTS = [
|
|
37
|
+
{ widths: [40, 32, 28], splits: [0.5, 0.6, 0.45] },
|
|
38
|
+
{ widths: [52, 24, 24], splits: [0.62, 0.4, 0.55] },
|
|
39
|
+
{ widths: [24, 52, 24], splits: [0.4, 0.55, 0.62] },
|
|
40
|
+
{ widths: [26, 28, 46], splits: [0.55, 0.5, 0.4] },
|
|
41
|
+
];
|
|
42
|
+
/** Horizontal/vertical gap between tiles, in percent. */
|
|
43
|
+
export const MOCKUP_GAP = 3;
|
|
44
|
+
/**
|
|
45
|
+
* Computes the six tile rects for one layout. Tile order is
|
|
46
|
+
* [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom].
|
|
47
|
+
*
|
|
48
|
+
* Columns keep a constant `gap` between them and top/bottom keep a constant
|
|
49
|
+
* `gap` between them — this is what makes every transition overlap-free.
|
|
50
|
+
*/
|
|
51
|
+
export function computeLayoutRects(layout, gap = MOCKUP_GAP) {
|
|
52
|
+
const usableW = 100 - 2 * gap;
|
|
53
|
+
const usableH = 100 - gap;
|
|
54
|
+
const sum = layout.widths[0] + layout.widths[1] + layout.widths[2];
|
|
55
|
+
const colW = layout.widths.map((w) => (w / sum) * usableW);
|
|
56
|
+
const colX = [0, colW[0] + gap, colW[0] + colW[1] + 2 * gap];
|
|
57
|
+
const rects = [];
|
|
58
|
+
for (let c = 0; c < 3; c++) {
|
|
59
|
+
const topH = layout.splits[c] * usableH;
|
|
60
|
+
const botH = usableH - topH;
|
|
61
|
+
rects.push({ x: colX[c], y: 0, w: colW[c], h: topH });
|
|
62
|
+
rects.push({ x: colX[c], y: topH + gap, w: colW[c], h: botH });
|
|
63
|
+
}
|
|
64
|
+
return rects;
|
|
65
|
+
}
|
|
66
|
+
/** The precomputed rects for all four layouts (exported for the overlap test). */
|
|
67
|
+
export function mockupLayoutRects(gap = MOCKUP_GAP) {
|
|
68
|
+
return MOCKUP_LAYOUTS.map((l) => computeLayoutRects(l, gap));
|
|
55
69
|
}
|
|
70
|
+
// Keyframe stops. Cycle = 12.8s: per layout ~2s rest + ~1.2s glide. The stops
|
|
71
|
+
// hold each layout, then glide to the next; 100% == 0% (layout A) → seamless.
|
|
72
|
+
const STOPS = [0, 15.625, 25, 40.625, 50, 65.625, 75, 90.625, 100];
|
|
73
|
+
const STOP_LAYOUT = [0, 0, 1, 1, 2, 2, 3, 3, 0]; // which layout at each stop
|
|
74
|
+
const fmt = (n) => `${Math.round(n * 1000) / 1000}%`;
|
|
75
|
+
/** Builds the full <style> text: per-tile keyframes cycling through the rects. */
|
|
76
|
+
function buildCss() {
|
|
77
|
+
const layouts = mockupLayoutRects();
|
|
78
|
+
const tiles = layouts[0].length;
|
|
79
|
+
let css = `
|
|
80
|
+
.mc-demock{position:relative;height:100%;width:100%}
|
|
81
|
+
.mc-demock-tile{position:absolute;
|
|
82
|
+
animation-duration:12.8s;animation-timing-function:cubic-bezier(.65,0,.35,1);
|
|
83
|
+
animation-iteration-count:infinite;will-change:top,left,width,height}
|
|
84
|
+
.mc-demock-shimmer{overflow:hidden}
|
|
85
|
+
.mc-demock-shimmer::after{content:"";position:absolute;inset:0;
|
|
86
|
+
background:linear-gradient(105deg,transparent 35%,currentColor 50%,transparent 65%);
|
|
87
|
+
opacity:.045;transform:translateX(-120%);
|
|
88
|
+
animation:mc-demock-sheen 12.8s ease-in-out infinite}
|
|
89
|
+
@keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,72%{transform:translateX(120%)}}
|
|
56
90
|
`;
|
|
91
|
+
for (let i = 0; i < tiles; i++) {
|
|
92
|
+
css += `.mc-demock-t${i}{animation-name:mc-demock-k${i}}\n@keyframes mc-demock-k${i}{`;
|
|
93
|
+
for (let s = 0; s < STOPS.length; s++) {
|
|
94
|
+
const r = layouts[STOP_LAYOUT[s]][i];
|
|
95
|
+
css += `${fmt(STOPS[s])}{left:${fmt(r.x)};top:${fmt(r.y)};width:${fmt(r.w)};height:${fmt(r.h)}}`;
|
|
96
|
+
}
|
|
97
|
+
css += `}\n`;
|
|
98
|
+
}
|
|
99
|
+
css += `@media (prefers-reduced-motion:reduce){.mc-demock-tile{animation:none!important}.mc-demock-shimmer::after{animation:none!important;opacity:0}}\n`;
|
|
100
|
+
return css;
|
|
101
|
+
}
|
|
57
102
|
/** Injects the keyframes once per document (idempotent, SSR-safe). */
|
|
58
103
|
function useMockupStyles() {
|
|
59
104
|
React.useInsertionEffect(() => {
|
|
@@ -63,16 +108,39 @@ function useMockupStyles() {
|
|
|
63
108
|
return;
|
|
64
109
|
const el = document.createElement('style');
|
|
65
110
|
el.id = STYLE_ID;
|
|
66
|
-
el.textContent =
|
|
111
|
+
el.textContent = buildCss();
|
|
67
112
|
document.head.appendChild(el);
|
|
68
113
|
}, []);
|
|
69
114
|
}
|
|
70
|
-
const
|
|
115
|
+
const tileBase = 'rounded-lg border border-border/60 bg-card mc-demock-tile mc-demock-shimmer text-foreground';
|
|
116
|
+
// Header shared by every card kind — icon chip + title bar + subtitle bar,
|
|
117
|
+
// mirroring the real WidgetCard chrome (widgets/widget-card.tsx).
|
|
118
|
+
function SkeletonHeader() {
|
|
119
|
+
return (_jsxs("div", { className: "flex items-start gap-2.5", children: [_jsx("div", { className: "size-8 shrink-0 rounded-lg bg-foreground/10" }), _jsxs("div", { className: "flex-1 space-y-1.5 pt-0.5", children: [_jsx("div", { className: "h-2.5 w-2/3 rounded bg-foreground/12" }), _jsx("div", { className: "h-2 w-2/5 rounded bg-foreground/[.07]" })] })] }));
|
|
120
|
+
}
|
|
121
|
+
// Bodies copy the anatomy of the matching real renderers (renderers.tsx).
|
|
122
|
+
function TileGlyph({ kind }) {
|
|
123
|
+
return (_jsxs("div", { className: "flex h-full flex-col gap-3 overflow-hidden p-3", children: [_jsx(SkeletonHeader, {}), _jsxs("div", { className: "min-h-0 flex-1", children: [kind === 'stat' && (_jsxs("div", { className: "flex h-full flex-col justify-center gap-2", children: [_jsx("div", { className: "h-6 w-1/2 rounded-md bg-foreground/15" }), _jsx("div", { className: "h-4 w-14 rounded-full bg-foreground/10" })] })), kind === 'chart' && (_jsx("div", { className: "flex h-full items-end gap-1.5", children: [46, 72, 38, 84, 58, 92, 50].map((h, i) => (_jsx("div", { className: "w-full rounded-sm bg-foreground/10", style: { height: `${h}%` } }, i))) })), kind === 'list' && (_jsx("div", { className: "flex h-full flex-col justify-center gap-2.5", children: [28, 40, 34].map((w, i) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "size-5 shrink-0 rounded-full bg-foreground/10" }), _jsx("div", { className: "h-2 flex-1 rounded-full bg-foreground/10" }), _jsx("div", { className: "h-2 shrink-0 rounded-full bg-foreground/12", style: { width: w } })] }, i))) })), kind === 'bar' && (_jsxs("div", { className: "flex h-full flex-col justify-center gap-3", children: [_jsx("div", { className: "h-2.5 w-full rounded-full bg-foreground/10", children: _jsx("div", { className: "h-full w-3/5 rounded-full bg-foreground/20" }) }), _jsx("div", { className: "h-2.5 w-full rounded-full bg-foreground/10", children: _jsx("div", { className: "h-full w-2/5 rounded-full bg-foreground/20" }) })] }))] })] }));
|
|
124
|
+
}
|
|
125
|
+
// Kind per tile, in the [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom]
|
|
126
|
+
// order used by computeLayoutRects.
|
|
127
|
+
const TILE_KINDS = ['stat', 'chart', 'chart', 'list', 'stat', 'bar'];
|
|
71
128
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
129
|
+
* Full-bleed skeleton dashboard that reflows through four layouts on a loop.
|
|
130
|
+
* Overlap-free by construction (see the invariant note above). Purely
|
|
131
|
+
* decorative — always `aria-hidden`, no text.
|
|
132
|
+
*
|
|
133
|
+
* Contract: tiles are positioned with percentage heights, so the CONTAINER
|
|
134
|
+
* must have a DEFINITE height (e.g. `h-[…]`, or a flex child with `flex-1`).
|
|
135
|
+
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
136
|
+
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
137
|
+
* definite-height box for exactly this reason.
|
|
74
138
|
*/
|
|
75
139
|
export function DashboardEmptyMockup({ className }) {
|
|
76
140
|
useMockupStyles();
|
|
77
|
-
|
|
141
|
+
const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), []);
|
|
142
|
+
return (_jsx("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('mc-demock pointer-events-none select-none', className), children: layoutA.map((r, i) => (_jsx("div", { "data-mockup-tile": "tile", className: cn(tileBase, `mc-demock-t${i}`),
|
|
143
|
+
// Base position = layout A, so reduced-motion (animation off)
|
|
144
|
+
// shows the full static composition.
|
|
145
|
+
style: { left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` }, children: _jsx(TileGlyph, { kind: TILE_KINDS[i] }) }, i))) }));
|
|
78
146
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBAmJpB"}
|
package/dist/dashboard-grid.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// DashboardGrid — the modular dashboard surface. Renders the union of
|
|
3
3
|
// declarative + federated widgets in a responsive 4-column grid, grouped with
|
|
4
4
|
// headings, honouring per-widget size, permission gating (useCan), batch data
|
|
@@ -120,7 +120,18 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
|
|
|
120
120
|
}, [visibleGroups]);
|
|
121
121
|
// Global empty state (no widgets at all / none visible after gating).
|
|
122
122
|
if (visibleGroups.length === 0) {
|
|
123
|
-
return (
|
|
123
|
+
return (_jsx("div", { "data-testid": "dashboard-empty", className: cn(
|
|
124
|
+
// Full-bleed: the mockup fills the whole dashboard area, not a
|
|
125
|
+
// centered illustration. No caption — the animation speaks for
|
|
126
|
+
// itself (and dodges shipping un-localized copy).
|
|
127
|
+
//
|
|
128
|
+
// DEFINITE height (clamp), not min-height: the mockup positions
|
|
129
|
+
// its tiles with percentage heights, which only resolve against a
|
|
130
|
+
// parent whose height is definite. A min-height alone leaves the
|
|
131
|
+
// box auto-height and the tiles collapse into a strip — so we own
|
|
132
|
+
// the height here instead of hoping the dashboard layout supplies
|
|
133
|
+
// one.
|
|
134
|
+
'h-[clamp(420px,60vh,720px)] w-full rounded-xl border border-dashed border-border/60 p-4', className), children: _jsx(DashboardEmptyMockup, { className: "h-full" }) }));
|
|
124
135
|
}
|
|
125
136
|
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
126
137
|
// Masonry: balanced CSS columns. Cards take their natural height
|
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Zero-overlap invariant for the reflowing empty-state mockup.
|
|
2
|
+
//
|
|
3
|
+
// The animation reflows through four layouts (A→B→C→D→A). The hard requirement
|
|
4
|
+
// is that NO two tiles overlap at ANY frame — including mid-transition, where
|
|
5
|
+
// CSS interpolates each tile's rect (left/top/width/height) linearly. This test
|
|
6
|
+
// reproduces that linear interpolation and asserts non-overlap across every
|
|
7
|
+
// transition at fine time steps, so a future layout edit that would introduce a
|
|
8
|
+
// crossing fails here instead of in production.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
import {
|
|
12
|
+
MockupRect,
|
|
13
|
+
mockupLayoutRects,
|
|
14
|
+
} from '../dashboard-empty-mockup'
|
|
15
|
+
|
|
16
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
|
17
|
+
const lerpRect = (a: MockupRect, b: MockupRect, t: number): MockupRect => ({
|
|
18
|
+
x: lerp(a.x, b.x, t),
|
|
19
|
+
y: lerp(a.y, b.y, t),
|
|
20
|
+
w: lerp(a.w, b.w, t),
|
|
21
|
+
h: lerp(a.h, b.h, t),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Overlap area with a tiny epsilon so exact edge-adjacency (gap = 0 would-be
|
|
25
|
+
// touching) does not count; here tiles are separated by a real gap anyway.
|
|
26
|
+
const EPS = 1e-6
|
|
27
|
+
function overlapArea(a: MockupRect, b: MockupRect): number {
|
|
28
|
+
const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
|
29
|
+
const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
|
30
|
+
if (ox <= EPS || oy <= EPS) return 0
|
|
31
|
+
return ox * oy
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('dashboard empty mockup — zero-overlap invariant', () => {
|
|
35
|
+
const layouts = mockupLayoutRects()
|
|
36
|
+
|
|
37
|
+
it('has 6 tiles in every layout, all within bounds', () => {
|
|
38
|
+
for (const rects of layouts) {
|
|
39
|
+
expect(rects).toHaveLength(6)
|
|
40
|
+
for (const r of rects) {
|
|
41
|
+
expect(r.x).toBeGreaterThanOrEqual(-EPS)
|
|
42
|
+
expect(r.y).toBeGreaterThanOrEqual(-EPS)
|
|
43
|
+
expect(r.x + r.w).toBeLessThanOrEqual(100 + EPS)
|
|
44
|
+
expect(r.y + r.h).toBeLessThanOrEqual(100 + EPS)
|
|
45
|
+
expect(r.w).toBeGreaterThan(0)
|
|
46
|
+
expect(r.h).toBeGreaterThan(0)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('no two tiles overlap in any static layout', () => {
|
|
52
|
+
for (let l = 0; l < layouts.length; l++) {
|
|
53
|
+
const rects = layouts[l]
|
|
54
|
+
for (let i = 0; i < rects.length; i++)
|
|
55
|
+
for (let j = i + 1; j < rects.length; j++)
|
|
56
|
+
expect(overlapArea(rects[i], rects[j]), `layout ${l}, tiles ${i}&${j}`).toBe(0)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('no two tiles overlap at any frame of any transition (A→B→C→D→A)', () => {
|
|
61
|
+
// The loop is cyclic: transition k goes layout k → (k+1) mod 4.
|
|
62
|
+
const n = layouts.length
|
|
63
|
+
let worst = 0
|
|
64
|
+
for (let k = 0; k < n; k++) {
|
|
65
|
+
const from = layouts[k]
|
|
66
|
+
const to = layouts[(k + 1) % n]
|
|
67
|
+
for (let step = 0; step <= 20; step++) {
|
|
68
|
+
const t = step / 20 // 0, 0.05, … , 1
|
|
69
|
+
const frame = from.map((r, idx) => lerpRect(r, to[idx], t))
|
|
70
|
+
for (let i = 0; i < frame.length; i++)
|
|
71
|
+
for (let j = i + 1; j < frame.length; j++) {
|
|
72
|
+
const area = overlapArea(frame[i], frame[j])
|
|
73
|
+
worst = Math.max(worst, area)
|
|
74
|
+
expect(
|
|
75
|
+
area,
|
|
76
|
+
`transition ${k}→${(k + 1) % n} t=${t.toFixed(2)} tiles ${i}&${j}`,
|
|
77
|
+
).toBe(0)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
expect(worst).toBe(0)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -164,6 +164,9 @@ describe('DashboardGrid render', () => {
|
|
|
164
164
|
expect(mock.getAttribute('aria-hidden')).toBe('true')
|
|
165
165
|
// Keyframes are injected once into the document head.
|
|
166
166
|
expect(document.getElementById('mc-dashboard-empty-mockup-style')).toBeTruthy()
|
|
167
|
+
// No caption text — the animation carries the meaning (and avoids
|
|
168
|
+
// shipping un-localized copy from the package).
|
|
169
|
+
expect(screen.queryByText(/dashboard/i)).toBeNull()
|
|
167
170
|
})
|
|
168
171
|
|
|
169
172
|
it('survives an empty → populated transition (React #310 regression)', async () => {
|
|
@@ -1,61 +1,129 @@
|
|
|
1
1
|
// DashboardEmptyMockup — the animated empty state for the modular dashboard.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// The whole grid REORGANIZES on a loop: skeleton widget cards grow, shrink and
|
|
4
|
+
// reflow through four dashboard compositions (A → B → C → D → A), as if the
|
|
5
|
+
// board were trying out arrangements while it loads. No "empty" copy — the
|
|
6
|
+
// motion carries the meaning.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// - Theme tokens only (bg-muted / border tokens) → light & dark for free.
|
|
12
|
-
// - `prefers-reduced-motion: reduce` freezes everything (tiles rest in place).
|
|
13
|
-
// - The whole mock is decorative → aria-hidden; the copy below carries the
|
|
14
|
-
// accessible meaning.
|
|
8
|
+
// ── The zero-overlap invariant (hard requirement) ──────────────────────────
|
|
9
|
+
// Two earlier takes let tiles cross and overlap; the user rejected them. This
|
|
10
|
+
// version makes overlap IMPOSSIBLE by construction, not by tuning:
|
|
15
11
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
12
|
+
// The board is 3 COLUMNS in a FIXED left-to-right order, each split into a
|
|
13
|
+
// TOP and BOTTOM tile — 6 tiles total. Every layout only changes the column
|
|
14
|
+
// WIDTHS and the per-column top/bottom SPLIT. Because column order never
|
|
15
|
+
// changes and top is always above bottom, EVERY pair of tiles keeps a
|
|
16
|
+
// consistent separating axis in every layout, with a constant `gap` between
|
|
17
|
+
// neighbours. Under linear interpolation a gap that is `gap` at both ends of
|
|
18
|
+
// a transition stays exactly `gap` throughout — so no two tiles can ever
|
|
19
|
+
// touch, at any frame, during any transition. (Verified independently by the
|
|
20
|
+
// interpolation check in dashboard-empty-mockup.test — 0 overlaps.)
|
|
21
|
+
//
|
|
22
|
+
// Design constraints kept from the spec:
|
|
23
|
+
// - Full-bleed (fills the whole dashboard area).
|
|
24
|
+
// - Pure CSS keyframes, no dependencies; injected once per document.
|
|
25
|
+
// - Synchronised reflow: all tiles share one duration / easing / timing, so
|
|
26
|
+
// A→B reads as a single reorganisation. ~2s rest per layout, ~1.2s glide.
|
|
27
|
+
// - Theme tokens only (bg-card / border / muted) → light & dark for free.
|
|
28
|
+
// - `prefers-reduced-motion: reduce` → the static A layout, no motion.
|
|
29
|
+
// - Decorative → aria-hidden, no text.
|
|
21
30
|
|
|
22
31
|
import * as React from 'react'
|
|
23
32
|
import { cn } from '@asteby/metacore-ui/lib'
|
|
24
33
|
|
|
25
|
-
// Scoped, collision-proof keyframe + class names (prefixed, unlikely to clash).
|
|
26
34
|
const STYLE_ID = 'mc-dashboard-empty-mockup-style'
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.
|
|
38
|
-
.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
36
|
+
/** A tile's rectangle in percent of the container (both axes 0–100). */
|
|
37
|
+
export interface MockupRect {
|
|
38
|
+
x: number
|
|
39
|
+
y: number
|
|
40
|
+
w: number
|
|
41
|
+
h: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Each layout = three column width weights + each column's top-tile height
|
|
45
|
+
// fraction. Weights are relative (normalised below), so they read as intent
|
|
46
|
+
// ("this column is the wide one") rather than exact percentages.
|
|
47
|
+
interface LayoutSpec {
|
|
48
|
+
widths: [number, number, number]
|
|
49
|
+
splits: [number, number, number]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Four visibly different compositions: balanced → left-heavy → centre-heavy →
|
|
53
|
+
// right-heavy. The loop returns to A, so it is seamless.
|
|
54
|
+
export const MOCKUP_LAYOUTS: LayoutSpec[] = [
|
|
55
|
+
{ widths: [40, 32, 28], splits: [0.5, 0.6, 0.45] },
|
|
56
|
+
{ widths: [52, 24, 24], splits: [0.62, 0.4, 0.55] },
|
|
57
|
+
{ widths: [24, 52, 24], splits: [0.4, 0.55, 0.62] },
|
|
58
|
+
{ widths: [26, 28, 46], splits: [0.55, 0.5, 0.4] },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** Horizontal/vertical gap between tiles, in percent. */
|
|
62
|
+
export const MOCKUP_GAP = 3
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Computes the six tile rects for one layout. Tile order is
|
|
66
|
+
* [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom].
|
|
67
|
+
*
|
|
68
|
+
* Columns keep a constant `gap` between them and top/bottom keep a constant
|
|
69
|
+
* `gap` between them — this is what makes every transition overlap-free.
|
|
70
|
+
*/
|
|
71
|
+
export function computeLayoutRects(layout: LayoutSpec, gap = MOCKUP_GAP): MockupRect[] {
|
|
72
|
+
const usableW = 100 - 2 * gap
|
|
73
|
+
const usableH = 100 - gap
|
|
74
|
+
const sum = layout.widths[0] + layout.widths[1] + layout.widths[2]
|
|
75
|
+
const colW = layout.widths.map((w) => (w / sum) * usableW) as [number, number, number]
|
|
76
|
+
const colX = [0, colW[0] + gap, colW[0] + colW[1] + 2 * gap]
|
|
77
|
+
|
|
78
|
+
const rects: MockupRect[] = []
|
|
79
|
+
for (let c = 0; c < 3; c++) {
|
|
80
|
+
const topH = layout.splits[c] * usableH
|
|
81
|
+
const botH = usableH - topH
|
|
82
|
+
rects.push({ x: colX[c], y: 0, w: colW[c], h: topH })
|
|
83
|
+
rects.push({ x: colX[c], y: topH + gap, w: colW[c], h: botH })
|
|
84
|
+
}
|
|
85
|
+
return rects
|
|
57
86
|
}
|
|
87
|
+
|
|
88
|
+
/** The precomputed rects for all four layouts (exported for the overlap test). */
|
|
89
|
+
export function mockupLayoutRects(gap = MOCKUP_GAP): MockupRect[][] {
|
|
90
|
+
return MOCKUP_LAYOUTS.map((l) => computeLayoutRects(l, gap))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Keyframe stops. Cycle = 12.8s: per layout ~2s rest + ~1.2s glide. The stops
|
|
94
|
+
// hold each layout, then glide to the next; 100% == 0% (layout A) → seamless.
|
|
95
|
+
const STOPS = [0, 15.625, 25, 40.625, 50, 65.625, 75, 90.625, 100]
|
|
96
|
+
const STOP_LAYOUT = [0, 0, 1, 1, 2, 2, 3, 3, 0] // which layout at each stop
|
|
97
|
+
|
|
98
|
+
const fmt = (n: number) => `${Math.round(n * 1000) / 1000}%`
|
|
99
|
+
|
|
100
|
+
/** Builds the full <style> text: per-tile keyframes cycling through the rects. */
|
|
101
|
+
function buildCss(): string {
|
|
102
|
+
const layouts = mockupLayoutRects()
|
|
103
|
+
const tiles = layouts[0].length
|
|
104
|
+
let css = `
|
|
105
|
+
.mc-demock{position:relative;height:100%;width:100%}
|
|
106
|
+
.mc-demock-tile{position:absolute;
|
|
107
|
+
animation-duration:12.8s;animation-timing-function:cubic-bezier(.65,0,.35,1);
|
|
108
|
+
animation-iteration-count:infinite;will-change:top,left,width,height}
|
|
109
|
+
.mc-demock-shimmer{overflow:hidden}
|
|
110
|
+
.mc-demock-shimmer::after{content:"";position:absolute;inset:0;
|
|
111
|
+
background:linear-gradient(105deg,transparent 35%,currentColor 50%,transparent 65%);
|
|
112
|
+
opacity:.045;transform:translateX(-120%);
|
|
113
|
+
animation:mc-demock-sheen 12.8s ease-in-out infinite}
|
|
114
|
+
@keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,72%{transform:translateX(120%)}}
|
|
58
115
|
`
|
|
116
|
+
for (let i = 0; i < tiles; i++) {
|
|
117
|
+
css += `.mc-demock-t${i}{animation-name:mc-demock-k${i}}\n@keyframes mc-demock-k${i}{`
|
|
118
|
+
for (let s = 0; s < STOPS.length; s++) {
|
|
119
|
+
const r = layouts[STOP_LAYOUT[s]][i]
|
|
120
|
+
css += `${fmt(STOPS[s])}{left:${fmt(r.x)};top:${fmt(r.y)};width:${fmt(r.w)};height:${fmt(r.h)}}`
|
|
121
|
+
}
|
|
122
|
+
css += `}\n`
|
|
123
|
+
}
|
|
124
|
+
css += `@media (prefers-reduced-motion:reduce){.mc-demock-tile{animation:none!important}.mc-demock-shimmer::after{animation:none!important;opacity:0}}\n`
|
|
125
|
+
return css
|
|
126
|
+
}
|
|
59
127
|
|
|
60
128
|
/** Injects the keyframes once per document (idempotent, SSR-safe). */
|
|
61
129
|
function useMockupStyles() {
|
|
@@ -64,59 +132,118 @@ function useMockupStyles() {
|
|
|
64
132
|
if (document.getElementById(STYLE_ID)) return
|
|
65
133
|
const el = document.createElement('style')
|
|
66
134
|
el.id = STYLE_ID
|
|
67
|
-
el.textContent =
|
|
135
|
+
el.textContent = buildCss()
|
|
68
136
|
document.head.appendChild(el)
|
|
69
137
|
}, [])
|
|
70
138
|
}
|
|
71
139
|
|
|
72
|
-
const
|
|
140
|
+
const tileBase =
|
|
141
|
+
'rounded-lg border border-border/60 bg-card mc-demock-tile mc-demock-shimmer text-foreground'
|
|
142
|
+
|
|
143
|
+
type Glyph = 'chart' | 'stat' | 'list' | 'bar'
|
|
144
|
+
|
|
145
|
+
// Header shared by every card kind — icon chip + title bar + subtitle bar,
|
|
146
|
+
// mirroring the real WidgetCard chrome (widgets/widget-card.tsx).
|
|
147
|
+
function SkeletonHeader() {
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex items-start gap-2.5">
|
|
150
|
+
<div className="size-8 shrink-0 rounded-lg bg-foreground/10" />
|
|
151
|
+
<div className="flex-1 space-y-1.5 pt-0.5">
|
|
152
|
+
<div className="h-2.5 w-2/3 rounded bg-foreground/12" />
|
|
153
|
+
<div className="h-2 w-2/5 rounded bg-foreground/[.07]" />
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Bodies copy the anatomy of the matching real renderers (renderers.tsx).
|
|
160
|
+
function TileGlyph({ kind }: { kind: Glyph }) {
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex h-full flex-col gap-3 overflow-hidden p-3">
|
|
163
|
+
<SkeletonHeader />
|
|
164
|
+
<div className="min-h-0 flex-1">
|
|
165
|
+
{kind === 'stat' && (
|
|
166
|
+
<div className="flex h-full flex-col justify-center gap-2">
|
|
167
|
+
<div className="h-6 w-1/2 rounded-md bg-foreground/15" />
|
|
168
|
+
<div className="h-4 w-14 rounded-full bg-foreground/10" />
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
{kind === 'chart' && (
|
|
172
|
+
<div className="flex h-full items-end gap-1.5">
|
|
173
|
+
{[46, 72, 38, 84, 58, 92, 50].map((h, i) => (
|
|
174
|
+
<div
|
|
175
|
+
key={i}
|
|
176
|
+
className="w-full rounded-sm bg-foreground/10"
|
|
177
|
+
style={{ height: `${h}%` }}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
{kind === 'list' && (
|
|
183
|
+
<div className="flex h-full flex-col justify-center gap-2.5">
|
|
184
|
+
{[28, 40, 34].map((w, i) => (
|
|
185
|
+
<div key={i} className="flex items-center gap-2">
|
|
186
|
+
<div className="size-5 shrink-0 rounded-full bg-foreground/10" />
|
|
187
|
+
<div className="h-2 flex-1 rounded-full bg-foreground/10" />
|
|
188
|
+
<div
|
|
189
|
+
className="h-2 shrink-0 rounded-full bg-foreground/12"
|
|
190
|
+
style={{ width: w }}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
{kind === 'bar' && (
|
|
197
|
+
<div className="flex h-full flex-col justify-center gap-3">
|
|
198
|
+
<div className="h-2.5 w-full rounded-full bg-foreground/10">
|
|
199
|
+
<div className="h-full w-3/5 rounded-full bg-foreground/20" />
|
|
200
|
+
</div>
|
|
201
|
+
<div className="h-2.5 w-full rounded-full bg-foreground/10">
|
|
202
|
+
<div className="h-full w-2/5 rounded-full bg-foreground/20" />
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Kind per tile, in the [c0-top, c0-bottom, c1-top, c1-bottom, c2-top, c2-bottom]
|
|
212
|
+
// order used by computeLayoutRects.
|
|
213
|
+
const TILE_KINDS: Glyph[] = ['stat', 'chart', 'chart', 'list', 'stat', 'bar']
|
|
73
214
|
|
|
74
215
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
216
|
+
* Full-bleed skeleton dashboard that reflows through four layouts on a loop.
|
|
217
|
+
* Overlap-free by construction (see the invariant note above). Purely
|
|
218
|
+
* decorative — always `aria-hidden`, no text.
|
|
219
|
+
*
|
|
220
|
+
* Contract: tiles are positioned with percentage heights, so the CONTAINER
|
|
221
|
+
* must have a DEFINITE height (e.g. `h-[…]`, or a flex child with `flex-1`).
|
|
222
|
+
* A parent with only `min-height` leaves the box auto-height and the tiles
|
|
223
|
+
* collapse into a strip. The dashboard empty state mounts it inside a
|
|
224
|
+
* definite-height box for exactly this reason.
|
|
77
225
|
*/
|
|
78
226
|
export function DashboardEmptyMockup({ className }: { className?: string }) {
|
|
79
227
|
useMockupStyles()
|
|
228
|
+
const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), [])
|
|
80
229
|
return (
|
|
81
230
|
<div
|
|
82
231
|
aria-hidden="true"
|
|
83
232
|
data-testid="dashboard-empty-mockup"
|
|
84
|
-
className={cn('mc-demock pointer-events-none
|
|
233
|
+
className={cn('mc-demock pointer-events-none select-none', className)}
|
|
85
234
|
>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
</div>
|
|
97
|
-
{/* Stat card */}
|
|
98
|
-
<div className={cn(tile, 'mc-demock-t2 flex flex-col justify-center gap-2 p-3')}>
|
|
99
|
-
<div className="h-2 w-1/2 rounded-full bg-foreground/15" />
|
|
100
|
-
<div className="h-4 w-2/3 rounded bg-foreground/15" />
|
|
235
|
+
{layoutA.map((r, i) => (
|
|
236
|
+
<div
|
|
237
|
+
key={i}
|
|
238
|
+
data-mockup-tile="tile"
|
|
239
|
+
className={cn(tileBase, `mc-demock-t${i}`)}
|
|
240
|
+
// Base position = layout A, so reduced-motion (animation off)
|
|
241
|
+
// shows the full static composition.
|
|
242
|
+
style={{ left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` }}
|
|
243
|
+
>
|
|
244
|
+
<TileGlyph kind={TILE_KINDS[i]} />
|
|
101
245
|
</div>
|
|
102
|
-
|
|
103
|
-
<div className={cn(tile, 'mc-demock-t3 flex flex-col justify-center gap-2 p-3')}>
|
|
104
|
-
<div className="h-2 w-1/2 rounded-full bg-foreground/15" />
|
|
105
|
-
<div className="h-4 w-1/2 rounded bg-foreground/15" />
|
|
106
|
-
</div>
|
|
107
|
-
{/* List tile */}
|
|
108
|
-
<div className={cn(tile, 'mc-demock-t4 flex flex-col justify-center gap-2 p-3')}>
|
|
109
|
-
<div className="h-2 w-full rounded-full bg-foreground/12" />
|
|
110
|
-
<div className="h-2 w-4/5 rounded-full bg-foreground/12" />
|
|
111
|
-
<div className="h-2 w-2/3 rounded-full bg-foreground/12" />
|
|
112
|
-
</div>
|
|
113
|
-
{/* Progress bar tile */}
|
|
114
|
-
<div className={cn(tile, 'mc-demock-t5 col-span-2 flex items-center px-3')}>
|
|
115
|
-
<div className="h-2.5 w-full rounded-full bg-foreground/10">
|
|
116
|
-
<div className="h-full w-2/5 rounded-full bg-foreground/20" />
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
246
|
+
))}
|
|
120
247
|
</div>
|
|
121
248
|
)
|
|
122
249
|
}
|
package/src/dashboard-grid.tsx
CHANGED
|
@@ -154,19 +154,21 @@ export function DashboardGrid({
|
|
|
154
154
|
<div
|
|
155
155
|
data-testid="dashboard-empty"
|
|
156
156
|
className={cn(
|
|
157
|
-
|
|
157
|
+
// Full-bleed: the mockup fills the whole dashboard area, not a
|
|
158
|
+
// centered illustration. No caption — the animation speaks for
|
|
159
|
+
// itself (and dodges shipping un-localized copy).
|
|
160
|
+
//
|
|
161
|
+
// DEFINITE height (clamp), not min-height: the mockup positions
|
|
162
|
+
// its tiles with percentage heights, which only resolve against a
|
|
163
|
+
// parent whose height is definite. A min-height alone leaves the
|
|
164
|
+
// box auto-height and the tiles collapse into a strip — so we own
|
|
165
|
+
// the height here instead of hoping the dashboard layout supplies
|
|
166
|
+
// one.
|
|
167
|
+
'h-[clamp(420px,60vh,720px)] w-full rounded-xl border border-dashed border-border/60 p-4',
|
|
158
168
|
className,
|
|
159
169
|
)}
|
|
160
170
|
>
|
|
161
|
-
<DashboardEmptyMockup />
|
|
162
|
-
<div className="flex flex-col items-center">
|
|
163
|
-
<h3 className="text-base font-semibold text-foreground">
|
|
164
|
-
{tr(undefined, s.emptyTitle) || s.emptyTitle}
|
|
165
|
-
</h3>
|
|
166
|
-
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
|
167
|
-
{s.emptyDescription}
|
|
168
|
-
</p>
|
|
169
|
-
</div>
|
|
171
|
+
<DashboardEmptyMockup className="h-full" />
|
|
170
172
|
</div>
|
|
171
173
|
)
|
|
172
174
|
}
|