@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 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
- * Animated skeleton of a dashboard (chart + stats + bar + list) whose tiles
4
- * slowly drift and settle. Purely decorative — always `aria-hidden`.
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":"AAqBA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAoD9B;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBA4CzE"}
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
- // Instead of a static "nothing here" card, we paint a silhouette of the
5
- // dashboard itself: skeleton tiles (a big chart, a couple of stat cards, a
6
- // bar, a list) that slowly drift and swap places, like puzzle pieces settling
7
- // into a layout. The metaphor is "your board is getting ready", not "empty".
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
- // Design constraints (kept deliberately simple so it ships from a package):
10
- // - Pure CSS keyframes, no dependencies. One slow ~11s loop, subtle motion
11
- // (small translate + scale, never opacity strobing).
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
- // The keyframes ship inline in a <style> tag rather than as Tailwind utilities:
18
- // consumer apps scan only their OWN source for Tailwind classes, so arbitrary
19
- // animation utilities declared here would never be generated in their build.
20
- // Static tokens (bg-muted, rounded-lg…) are standard classes the host emits
21
- // anyway, so those stay as className.
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
- const MOCKUP_CSS = `
27
- .mc-demock{--mc-demock-dur:11s}
28
- .mc-demock-tile{will-change:transform;transform-origin:center;
29
- animation-duration:var(--mc-demock-dur);animation-timing-function:cubic-bezier(.65,0,.35,1);
30
- animation-iteration-count:infinite}
31
- .mc-demock-t1{animation-name:mc-demock-drift-a}
32
- .mc-demock-t2{animation-name:mc-demock-drift-b;animation-delay:-1.4s}
33
- .mc-demock-t3{animation-name:mc-demock-drift-c;animation-delay:-3.1s}
34
- .mc-demock-t4{animation-name:mc-demock-drift-d;animation-delay:-2.2s}
35
- .mc-demock-t5{animation-name:mc-demock-drift-e;animation-delay:-4.6s}
36
- .mc-demock-shimmer{position:relative;overflow:hidden}
37
- .mc-demock-shimmer::after{content:"";position:absolute;inset:0;
38
- background:linear-gradient(105deg,transparent 30%,currentColor 50%,transparent 70%);
39
- opacity:.05;transform:translateX(-100%);
40
- animation:mc-demock-sheen var(--mc-demock-dur) ease-in-out infinite}
41
- @keyframes mc-demock-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1)}
42
- 50%{transform:translate3d(3%,4%,0) scale(1.015)}}
43
- @keyframes mc-demock-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1)}
44
- 50%{transform:translate3d(-4%,-3%,0) scale(.985)}}
45
- @keyframes mc-demock-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1)}
46
- 50%{transform:translate3d(5%,-2%,0) scale(1.02)}}
47
- @keyframes mc-demock-drift-d{0%,100%{transform:translate3d(0,0,0) scale(1)}
48
- 50%{transform:translate3d(-3%,3%,0) scale(.99)}}
49
- @keyframes mc-demock-drift-e{0%,100%{transform:translate3d(0,0,0) scale(1)}
50
- 50%{transform:translate3d(2%,-4%,0) scale(1.01)}}
51
- @keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,70%{transform:translateX(120%)}}
52
- @media (prefers-reduced-motion:reduce){
53
- .mc-demock-tile{animation:none!important}
54
- .mc-demock-shimmer::after{animation:none!important;opacity:0}
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 = MOCKUP_CSS;
111
+ el.textContent = buildCss();
67
112
  document.head.appendChild(el);
68
113
  }, []);
69
114
  }
70
- const tile = 'rounded-lg bg-muted mc-demock-tile mc-demock-shimmer text-foreground';
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
- * Animated skeleton of a dashboard (chart + stats + bar + list) whose tiles
73
- * slowly drift and settle. Purely decorative always `aria-hidden`.
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
- return (_jsx("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('mc-demock pointer-events-none w-full max-w-md select-none', className), children: _jsxs("div", { className: "grid grid-cols-3 grid-rows-[repeat(3,minmax(0,1fr))] gap-3 [aspect-ratio:16/11]", children: [_jsx("div", { className: cn(tile, 'mc-demock-t1 col-span-2 row-span-2 flex flex-col justify-end gap-2 p-3'), children: _jsxs("div", { className: "flex items-end gap-1.5", children: [_jsx("div", { className: "h-6 w-full rounded-sm bg-foreground/10" }), _jsx("div", { className: "h-10 w-full rounded-sm bg-foreground/10" }), _jsx("div", { className: "h-7 w-full rounded-sm bg-foreground/10" }), _jsx("div", { className: "h-12 w-full rounded-sm bg-foreground/10" }), _jsx("div", { className: "h-9 w-full rounded-sm bg-foreground/10" })] }) }), _jsxs("div", { className: cn(tile, 'mc-demock-t2 flex flex-col justify-center gap-2 p-3'), children: [_jsx("div", { className: "h-2 w-1/2 rounded-full bg-foreground/15" }), _jsx("div", { className: "h-4 w-2/3 rounded bg-foreground/15" })] }), _jsxs("div", { className: cn(tile, 'mc-demock-t3 flex flex-col justify-center gap-2 p-3'), children: [_jsx("div", { className: "h-2 w-1/2 rounded-full bg-foreground/15" }), _jsx("div", { className: "h-4 w-1/2 rounded bg-foreground/15" })] }), _jsxs("div", { className: cn(tile, 'mc-demock-t4 flex flex-col justify-center gap-2 p-3'), children: [_jsx("div", { className: "h-2 w-full rounded-full bg-foreground/12" }), _jsx("div", { className: "h-2 w-4/5 rounded-full bg-foreground/12" }), _jsx("div", { className: "h-2 w-2/3 rounded-full bg-foreground/12" })] }), _jsx("div", { className: cn(tile, 'mc-demock-t5 col-span-2 flex items-center px-3'), children: _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" }) }) })] }) }));
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,qBAiJpB"}
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"}
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 (_jsxs("div", { "data-testid": "dashboard-empty", className: cn('flex min-h-[40vh] flex-col items-center justify-center gap-6 rounded-xl border border-dashed border-border/60 p-10 text-center', className), children: [_jsx(DashboardEmptyMockup, {}), _jsxs("div", { className: "flex flex-col items-center", children: [_jsx("h3", { className: "text-base font-semibold text-foreground", children: tr(undefined, s.emptyTitle) || s.emptyTitle }), _jsx("p", { className: "mt-1 max-w-sm text-sm text-muted-foreground", children: s.emptyDescription })] })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "27.3.0",
3
+ "version": "27.3.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- // Instead of a static "nothing here" card, we paint a silhouette of the
4
- // dashboard itself: skeleton tiles (a big chart, a couple of stat cards, a
5
- // bar, a list) that slowly drift and swap places, like puzzle pieces settling
6
- // into a layout. The metaphor is "your board is getting ready", not "empty".
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
- // Design constraints (kept deliberately simple so it ships from a package):
9
- // - Pure CSS keyframes, no dependencies. One slow ~11s loop, subtle motion
10
- // (small translate + scale, never opacity strobing).
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
- // The keyframes ship inline in a <style> tag rather than as Tailwind utilities:
17
- // consumer apps scan only their OWN source for Tailwind classes, so arbitrary
18
- // animation utilities declared here would never be generated in their build.
19
- // Static tokens (bg-muted, rounded-lg…) are standard classes the host emits
20
- // anyway, so those stay as className.
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
- const MOCKUP_CSS = `
29
- .mc-demock{--mc-demock-dur:11s}
30
- .mc-demock-tile{will-change:transform;transform-origin:center;
31
- animation-duration:var(--mc-demock-dur);animation-timing-function:cubic-bezier(.65,0,.35,1);
32
- animation-iteration-count:infinite}
33
- .mc-demock-t1{animation-name:mc-demock-drift-a}
34
- .mc-demock-t2{animation-name:mc-demock-drift-b;animation-delay:-1.4s}
35
- .mc-demock-t3{animation-name:mc-demock-drift-c;animation-delay:-3.1s}
36
- .mc-demock-t4{animation-name:mc-demock-drift-d;animation-delay:-2.2s}
37
- .mc-demock-t5{animation-name:mc-demock-drift-e;animation-delay:-4.6s}
38
- .mc-demock-shimmer{position:relative;overflow:hidden}
39
- .mc-demock-shimmer::after{content:"";position:absolute;inset:0;
40
- background:linear-gradient(105deg,transparent 30%,currentColor 50%,transparent 70%);
41
- opacity:.05;transform:translateX(-100%);
42
- animation:mc-demock-sheen var(--mc-demock-dur) ease-in-out infinite}
43
- @keyframes mc-demock-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1)}
44
- 50%{transform:translate3d(3%,4%,0) scale(1.015)}}
45
- @keyframes mc-demock-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1)}
46
- 50%{transform:translate3d(-4%,-3%,0) scale(.985)}}
47
- @keyframes mc-demock-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1)}
48
- 50%{transform:translate3d(5%,-2%,0) scale(1.02)}}
49
- @keyframes mc-demock-drift-d{0%,100%{transform:translate3d(0,0,0) scale(1)}
50
- 50%{transform:translate3d(-3%,3%,0) scale(.99)}}
51
- @keyframes mc-demock-drift-e{0%,100%{transform:translate3d(0,0,0) scale(1)}
52
- 50%{transform:translate3d(2%,-4%,0) scale(1.01)}}
53
- @keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,70%{transform:translateX(120%)}}
54
- @media (prefers-reduced-motion:reduce){
55
- .mc-demock-tile{animation:none!important}
56
- .mc-demock-shimmer::after{animation:none!important;opacity:0}
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 = MOCKUP_CSS
135
+ el.textContent = buildCss()
68
136
  document.head.appendChild(el)
69
137
  }, [])
70
138
  }
71
139
 
72
- const tile = 'rounded-lg bg-muted mc-demock-tile mc-demock-shimmer text-foreground'
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
- * Animated skeleton of a dashboard (chart + stats + bar + list) whose tiles
76
- * slowly drift and settle. Purely decorative always `aria-hidden`.
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 w-full max-w-md select-none', className)}
233
+ className={cn('mc-demock pointer-events-none select-none', className)}
85
234
  >
86
- <div className="grid grid-cols-3 grid-rows-[repeat(3,minmax(0,1fr))] gap-3 [aspect-ratio:16/11]">
87
- {/* Big chart tile */}
88
- <div className={cn(tile, 'mc-demock-t1 col-span-2 row-span-2 flex flex-col justify-end gap-2 p-3')}>
89
- <div className="flex items-end gap-1.5">
90
- <div className="h-6 w-full rounded-sm bg-foreground/10" />
91
- <div className="h-10 w-full rounded-sm bg-foreground/10" />
92
- <div className="h-7 w-full rounded-sm bg-foreground/10" />
93
- <div className="h-12 w-full rounded-sm bg-foreground/10" />
94
- <div className="h-9 w-full rounded-sm bg-foreground/10" />
95
- </div>
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
- {/* Stat card */}
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
  }
@@ -154,19 +154,21 @@ export function DashboardGrid({
154
154
  <div
155
155
  data-testid="dashboard-empty"
156
156
  className={cn(
157
- 'flex min-h-[40vh] flex-col items-center justify-center gap-6 rounded-xl border border-dashed border-border/60 p-10 text-center',
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
  }