@asteby/metacore-runtime-react 27.3.1 → 27.3.3

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,30 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 27.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 31ba021: DynamicTable: clamp genérico de texto largo en celdas (max-width 350px + tooltip con el valor completo) y las cards móviles muestran el label traducido de la columna en vez de la key cruda.
8
+
9
+ ## 27.3.2
10
+
11
+ ### Patch Changes
12
+
13
+ - c58b455: Empty state del Tablero: el mockup animado ahora reorganiza toda la grilla en
14
+ loop, pasando por cuatro composiciones de dashboard (A→B→C→D→A) — las skeleton
15
+ cards crecen, se achican y se redistribuyen como widgets reales probando
16
+ acomodos mientras carga. ~2s de reposo por layout y ~1.2s de transición fluida
17
+ sincronizada (todas las cards transicionan a la vez, mismo easing).
18
+
19
+ Cero solapes garantizado por construcción: la grilla son 3 columnas en orden
20
+ fijo × top/bottom, y cada layout solo cambia anchos de columna y el split
21
+ top/bottom, así que todo par de tiles conserva un eje de separación consistente
22
+ con un `gap` constante — bajo interpolación lineal eso jamás se cruza. Se agrega
23
+ un test que interpola los rects (t=0..1, paso 0.05) en cada transición y asserta
24
+ 0 solapes (estático + en tránsito). Se mantiene tokens de tema, skeleton interno
25
+ tipo widget card, `prefers-reduced-motion: reduce` → grilla estática (layout A) y
26
+ `aria-hidden`.
27
+
3
28
  ## 27.3.1
4
29
 
5
30
  ### Patch Changes
@@ -1,10 +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
- * Full-bleed animated skeleton of a dashboard whose tiles slide and swap places,
4
- * as if the layout were assembling itself. Purely decorative — always
5
- * `aria-hidden`, no text.
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.
6
36
  */
7
37
  export declare function DashboardEmptyMockup({ className }: {
8
38
  className?: string;
9
39
  }): React.JSX.Element;
40
+ export {};
10
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":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AA2I9B;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAuBzE"}
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,81 +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 and let it BUILD ITSELF: skeleton tiles (a big chart, stat
6
- // cards, a bar, lists) that live in a full-bleed grid and visibly slide past
7
- // each other, swapping places like pieces reorganizing into a layout. The
8
- // metaphor is "your board is assembling", 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.
9
8
  //
10
- // Design constraints (kept simple so it ships from a package):
11
- // - Full-bleed: fills the whole dashboard area (100% w/h), NOT a centered
12
- // illustration. A responsive grid so it reads like the real dashboard.
13
- // - Pure CSS keyframes, no dependencies. One slow loop (~13s). Motion is
14
- // PERCEPTIBLE — tiles translate a real distance and swap in pairs — but
15
- // never opacity-strobing, and eased so it feels like settling.
16
- // - Theme tokens only (bg-muted / border tokens) → light & dark for free.
17
- // - `prefers-reduced-motion: reduce` freezes everything (tiles rest in place).
18
- // - No caption text — the animation carries the meaning, and shipping copy
19
- // from the package risks un-localized strings. The whole mock is
20
- // decorative → aria-hidden.
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:
21
12
  //
22
- // Keyframes ship inline in a <style> tag rather than as Tailwind utilities:
23
- // consumer apps scan only their OWN source for Tailwind classes, so arbitrary
24
- // animation utilities declared here would never be generated in their build.
25
- // Static tokens (bg-muted, rounded-lg…) are standard classes the host emits
26
- // 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.
27
31
  import * as React from 'react';
28
32
  import { cn } from '@asteby/metacore-ui/lib';
29
- // Scoped, collision-proof keyframe + class names (prefixed, unlikely to clash).
30
33
  const STYLE_ID = 'mc-dashboard-empty-mockup-style';
31
- // Each tile lives in a grid cell and animates on a shared ~13s loop. Pairs move
32
- // in opposite directions at the same phase so it reads as a SWAP, and the phases
33
- // are staggered (via negative delays) so 2-3 tiles are always in motion at
34
- // different moments of the cycle never a frozen frame.
35
- const MOCKUP_CSS = `
36
- .mc-demock{--mc-demock-dur:13s}
37
- .mc-demock-tile{will-change:transform;transform-origin:center;
38
- animation-duration:var(--mc-demock-dur);animation-timing-function:cubic-bezier(.65,0,.35,1);
39
- animation-iteration-count:infinite}
40
- /* Horizontal swap pair (col A <-> col B) */
41
- .mc-demock-swapL{animation-name:mc-demock-swap-left}
42
- .mc-demock-swapR{animation-name:mc-demock-swap-right;animation-delay:-.2s}
43
- /* Vertical swap pair */
44
- .mc-demock-swapU{animation-name:mc-demock-swap-up;animation-delay:-4.3s}
45
- .mc-demock-swapD{animation-name:mc-demock-swap-down;animation-delay:-4.5s}
46
- /* Diagonal reshuffle pair */
47
- .mc-demock-shuffleA{animation-name:mc-demock-shuffle-a;animation-delay:-8.1s}
48
- .mc-demock-shuffleB{animation-name:mc-demock-shuffle-b;animation-delay:-8.3s}
49
- /* Gentle drifters filling the rhythm between swaps */
50
- .mc-demock-driftA{animation-name:mc-demock-drift-a;animation-delay:-2.4s}
51
- .mc-demock-driftB{animation-name:mc-demock-drift-b;animation-delay:-6.7s}
52
- .mc-demock-shimmer{position:relative;overflow:hidden}
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));
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}
53
85
  .mc-demock-shimmer::after{content:"";position:absolute;inset:0;
54
86
  background:linear-gradient(105deg,transparent 35%,currentColor 50%,transparent 65%);
55
- opacity:.05;transform:translateX(-120%);
56
- animation:mc-demock-sheen var(--mc-demock-dur) ease-in-out infinite}
57
- /* Swaps: hold home -> travel a full cell (+gap) to the partner's slot -> hold
58
- -> travel back. ~108% of own size clears the tile plus the grid gap. */
59
- @keyframes mc-demock-swap-left{
60
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(108%,0)}88%,100%{transform:translate(0,0)}}
61
- @keyframes mc-demock-swap-right{
62
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(-108%,0)}88%,100%{transform:translate(0,0)}}
63
- @keyframes mc-demock-swap-up{
64
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(0,112%)}88%,100%{transform:translate(0,0)}}
65
- @keyframes mc-demock-swap-down{
66
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(0,-112%)}88%,100%{transform:translate(0,0)}}
67
- @keyframes mc-demock-shuffle-a{
68
- 0%,15%{transform:translate(0,0) scale(1)}45%,60%{transform:translate(106%,110%) scale(.96)}90%,100%{transform:translate(0,0) scale(1)}}
69
- @keyframes mc-demock-shuffle-b{
70
- 0%,15%{transform:translate(0,0) scale(1)}45%,60%{transform:translate(-106%,-110%) scale(1.04)}90%,100%{transform:translate(0,0) scale(1)}}
71
- @keyframes mc-demock-drift-a{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(4%,-5%) scale(1.02)}}
72
- @keyframes mc-demock-drift-b{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(-5%,4%) scale(.98)}}
87
+ opacity:.045;transform:translateX(-120%);
88
+ animation:mc-demock-sheen 12.8s ease-in-out infinite}
73
89
  @keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,72%{transform:translateX(120%)}}
74
- @media (prefers-reduced-motion:reduce){
75
- .mc-demock-tile{animation:none!important}
76
- .mc-demock-shimmer::after{animation:none!important;opacity:0}
77
- }
78
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
+ }
79
102
  /** Injects the keyframes once per document (idempotent, SSR-safe). */
80
103
  function useMockupStyles() {
81
104
  React.useInsertionEffect(() => {
@@ -85,52 +108,39 @@ function useMockupStyles() {
85
108
  return;
86
109
  const el = document.createElement('style');
87
110
  el.id = STYLE_ID;
88
- el.textContent = MOCKUP_CSS;
111
+ el.textContent = buildCss();
89
112
  document.head.appendChild(el);
90
113
  }, []);
91
114
  }
92
- const tileBase = 'rounded-lg bg-muted mc-demock-tile mc-demock-shimmer text-foreground overflow-hidden';
93
- /** A skeleton "chart" tile: a row of bars. */
94
- function ChartGlyph() {
95
- return (_jsx("div", { className: "flex h-full items-end gap-1.5 p-3", children: [6, 10, 7, 12, 9, 11, 8].map((h, i) => (_jsx("div", { className: "w-full rounded-sm bg-foreground/10", style: { height: `${h * 8}%` } }, i))) }));
96
- }
97
- /** A skeleton "stat card": label + big number. */
98
- function StatGlyph() {
99
- return (_jsxs("div", { className: "flex h-full 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" })] }));
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]" })] })] }));
100
120
  }
101
- /** A skeleton "list" tile: stacked rows. */
102
- function ListGlyph() {
103
- return (_jsxs("div", { className: "flex h-full 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" })] }));
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" }) })] }))] })] }));
104
124
  }
105
- /** A skeleton "progress bar" tile. */
106
- function BarGlyph() {
107
- return (_jsx("div", { className: "flex h-full 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" }) }) }));
108
- }
109
- const TILES = [
110
- // Big chart (top-left 2x2) — diagonal shuffle with the bottom stat.
111
- { area: { gridColumn: '1 / 3', gridRow: '1 / 3' }, motion: 'mc-demock-shuffleA', glyph: _jsx(ChartGlyph, {}) },
112
- // Horizontal swap pair, top-right.
113
- { area: { gridColumn: '3 / 4', gridRow: '1 / 2' }, motion: 'mc-demock-swapL', glyph: _jsx(StatGlyph, {}) },
114
- { area: { gridColumn: '4 / 5', gridRow: '1 / 2' }, motion: 'mc-demock-swapR', glyph: _jsx(StatGlyph, {}) },
115
- // Vertical swap pair, right column.
116
- { area: { gridColumn: '3 / 5', gridRow: '2 / 3' }, motion: 'mc-demock-swapU', glyph: _jsx(ListGlyph, {}) },
117
- { area: { gridColumn: '3 / 5', gridRow: '3 / 4' }, motion: 'mc-demock-swapD', glyph: _jsx(BarGlyph, {}) },
118
- // Bottom-left cluster: shuffle partner + drifter.
119
- { area: { gridColumn: '1 / 2', gridRow: '3 / 4' }, motion: 'mc-demock-shuffleB', glyph: _jsx(StatGlyph, {}) },
120
- { area: { gridColumn: '2 / 3', gridRow: '3 / 4' }, motion: 'mc-demock-driftA', glyph: _jsx(StatGlyph, {}) },
121
- // Full-width footer bar + trailing stat.
122
- { area: { gridColumn: '1 / 4', gridRow: '4 / 5' }, motion: 'mc-demock-driftB', glyph: _jsx(BarGlyph, {}) },
123
- { area: { gridColumn: '4 / 5', gridRow: '4 / 5' }, motion: 'mc-demock-driftA', glyph: _jsx(StatGlyph, {}) },
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'];
125
128
  /**
126
- * Full-bleed animated skeleton of a dashboard whose tiles slide and swap places,
127
- * as if the layout were assembling itself. Purely decorative — always
128
- * `aria-hidden`, no text.
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.
129
138
  */
130
139
  export function DashboardEmptyMockup({ className }) {
131
140
  useMockupStyles();
132
- return (_jsx("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('mc-demock pointer-events-none h-full w-full select-none', className), children: _jsx("div", { className: "grid h-full w-full gap-3", style: {
133
- gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
134
- gridTemplateRows: 'repeat(4, minmax(0, 1fr))',
135
- }, children: TILES.map((t, i) => (_jsx("div", { style: t.area, className: cn(tileBase, t.motion), children: t.glyph }, i))) }) }));
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))) }));
136
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,qBA4IpB"}
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"}
@@ -124,7 +124,14 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
124
124
  // Full-bleed: the mockup fills the whole dashboard area, not a
125
125
  // centered illustration. No caption — the animation speaks for
126
126
  // itself (and dodges shipping un-localized copy).
127
- 'min-h-[60vh] w-full rounded-xl border border-dashed border-border/60 p-4', className), children: _jsx(DashboardEmptyMockup, { className: "h-full min-h-[inherit]" }) }));
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" }) }));
128
135
  }
129
136
  return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
130
137
  // Masonry: balanced CSS columns. Cards take their natural height
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAwC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAwFhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOzE,CAAA;AA+ID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAqmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAwC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAQrD;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,KAAK,gBAAgB,KAAG,MAAM,GAAG,SAG5D,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAC7B,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,WAAW,MAAM,EACjB,SAAS,MAAM,KAChB,MAyBF,CAAA;AAyCD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAiB5D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OACqB,CAAA;AAwFhF;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOzE,CAAA;AA+ID;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA2mBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -731,12 +731,22 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
731
731
  if (typeof value === 'object' && value !== null) {
732
732
  return (_jsx(CollectionCell, { value: value, locale: currentLanguage, t: t, itemFields: col.itemFields ?? col.item_fields, getImageUrl: getImageUrl }));
733
733
  }
734
- if (col.key === 'description' ||
734
+ const text = value !== null && value !== undefined ? String(value) : '-';
735
+ // Clamp de texto largo GENÉRICO: un `truncate` en un
736
+ // span suelto no limita nada dentro de una celda de
737
+ // tabla (la celda crece con el contenido). Cualquier
738
+ // texto largo — biografías, notas, direcciones — se
739
+ // acota a 350px con tooltip del valor completo. La
740
+ // heurística por longitud cubre lo que la heurística
741
+ // por nombre ('description') dejaba pasar.
742
+ const isLongText = text.length > 60 ||
743
+ col.key === 'description' ||
735
744
  col.key === 'features' ||
736
- col.key.includes('description')) {
737
- return (_jsx("div", { className: "max-w-[350px]", title: String(value), children: _jsx("span", { className: "truncate font-medium block", children: value !== null && value !== undefined ? String(value) : '-' }) }));
745
+ col.key.includes('description');
746
+ if (isLongText) {
747
+ return (_jsx("div", { className: "max-w-[350px]", title: text, children: _jsx("span", { className: "truncate font-medium block", children: text }) }));
738
748
  }
739
- return (_jsx("span", { className: "truncate font-medium", children: value !== null && value !== undefined ? String(value) : '-' }));
749
+ return _jsx("span", { className: "truncate font-medium", children: text });
740
750
  }
741
751
  }
742
752
  },
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiEnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BA6qCnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiEnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAurCnB"}
@@ -976,8 +976,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
976
976
  const actionsCell = cells.find((c) => c.column.id === 'actions');
977
977
  const dataCells = cells.filter((c) => c.column.id !== 'actions' && c.column.id !== 'select');
978
978
  return (_jsxs("div", { "data-state": row.getIsSelected() && 'selected', className: cn('flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40', onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: [dataCells.map((cell) => {
979
+ // El label humano vive en columnDef.meta.label (lo
980
+ // adjunta la fábrica de columnas); el header casi
981
+ // nunca es string (es un componente), así que caer a
982
+ // column.id mostraba keys crudas ('user.avatar',
983
+ // 'created_at') en las cards móviles.
984
+ const cellMeta = cell.column.columnDef.meta;
979
985
  const header = cell.column.columnDef.header;
980
- const label = typeof header === 'string' ? header : cell.column.id;
986
+ const label = cellMeta?.label ??
987
+ (typeof header === 'string' ? header : cell.column.id);
981
988
  return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 break-words text-right font-medium', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
982
989
  }), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
983
990
  })) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), _jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "27.3.1",
3
+ "version": "27.3.3",
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
+ })
@@ -1,83 +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 and let it BUILD ITSELF: skeleton tiles (a big chart, stat
5
- // cards, a bar, lists) that live in a full-bleed grid and visibly slide past
6
- // each other, swapping places like pieces reorganizing into a layout. The
7
- // metaphor is "your board is assembling", 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.
8
7
  //
9
- // Design constraints (kept simple so it ships from a package):
10
- // - Full-bleed: fills the whole dashboard area (100% w/h), NOT a centered
11
- // illustration. A responsive grid so it reads like the real dashboard.
12
- // - Pure CSS keyframes, no dependencies. One slow loop (~13s). Motion is
13
- // PERCEPTIBLE — tiles translate a real distance and swap in pairs — but
14
- // never opacity-strobing, and eased so it feels like settling.
15
- // - Theme tokens only (bg-muted / border tokens) → light & dark for free.
16
- // - `prefers-reduced-motion: reduce` freezes everything (tiles rest in place).
17
- // - No caption text — the animation carries the meaning, and shipping copy
18
- // from the package risks un-localized strings. The whole mock is
19
- // decorative → aria-hidden.
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:
20
11
  //
21
- // Keyframes ship inline in a <style> tag rather than as Tailwind utilities:
22
- // consumer apps scan only their OWN source for Tailwind classes, so arbitrary
23
- // animation utilities declared here would never be generated in their build.
24
- // Static tokens (bg-muted, rounded-lg…) are standard classes the host emits
25
- // 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.
26
30
 
27
31
  import * as React from 'react'
28
32
  import { cn } from '@asteby/metacore-ui/lib'
29
33
 
30
- // Scoped, collision-proof keyframe + class names (prefixed, unlikely to clash).
31
34
  const STYLE_ID = 'mc-dashboard-empty-mockup-style'
32
35
 
33
- // Each tile lives in a grid cell and animates on a shared ~13s loop. Pairs move
34
- // in opposite directions at the same phase so it reads as a SWAP, and the phases
35
- // are staggered (via negative delays) so 2-3 tiles are always in motion at
36
- // different moments of the cycle — never a frozen frame.
37
- const MOCKUP_CSS = `
38
- .mc-demock{--mc-demock-dur:13s}
39
- .mc-demock-tile{will-change:transform;transform-origin:center;
40
- animation-duration:var(--mc-demock-dur);animation-timing-function:cubic-bezier(.65,0,.35,1);
41
- animation-iteration-count:infinite}
42
- /* Horizontal swap pair (col A <-> col B) */
43
- .mc-demock-swapL{animation-name:mc-demock-swap-left}
44
- .mc-demock-swapR{animation-name:mc-demock-swap-right;animation-delay:-.2s}
45
- /* Vertical swap pair */
46
- .mc-demock-swapU{animation-name:mc-demock-swap-up;animation-delay:-4.3s}
47
- .mc-demock-swapD{animation-name:mc-demock-swap-down;animation-delay:-4.5s}
48
- /* Diagonal reshuffle pair */
49
- .mc-demock-shuffleA{animation-name:mc-demock-shuffle-a;animation-delay:-8.1s}
50
- .mc-demock-shuffleB{animation-name:mc-demock-shuffle-b;animation-delay:-8.3s}
51
- /* Gentle drifters filling the rhythm between swaps */
52
- .mc-demock-driftA{animation-name:mc-demock-drift-a;animation-delay:-2.4s}
53
- .mc-demock-driftB{animation-name:mc-demock-drift-b;animation-delay:-6.7s}
54
- .mc-demock-shimmer{position:relative;overflow:hidden}
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
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}
55
110
  .mc-demock-shimmer::after{content:"";position:absolute;inset:0;
56
111
  background:linear-gradient(105deg,transparent 35%,currentColor 50%,transparent 65%);
57
- opacity:.05;transform:translateX(-120%);
58
- animation:mc-demock-sheen var(--mc-demock-dur) ease-in-out infinite}
59
- /* Swaps: hold home -> travel a full cell (+gap) to the partner's slot -> hold
60
- -> travel back. ~108% of own size clears the tile plus the grid gap. */
61
- @keyframes mc-demock-swap-left{
62
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(108%,0)}88%,100%{transform:translate(0,0)}}
63
- @keyframes mc-demock-swap-right{
64
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(-108%,0)}88%,100%{transform:translate(0,0)}}
65
- @keyframes mc-demock-swap-up{
66
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(0,112%)}88%,100%{transform:translate(0,0)}}
67
- @keyframes mc-demock-swap-down{
68
- 0%,12%{transform:translate(0,0)}38%,62%{transform:translate(0,-112%)}88%,100%{transform:translate(0,0)}}
69
- @keyframes mc-demock-shuffle-a{
70
- 0%,15%{transform:translate(0,0) scale(1)}45%,60%{transform:translate(106%,110%) scale(.96)}90%,100%{transform:translate(0,0) scale(1)}}
71
- @keyframes mc-demock-shuffle-b{
72
- 0%,15%{transform:translate(0,0) scale(1)}45%,60%{transform:translate(-106%,-110%) scale(1.04)}90%,100%{transform:translate(0,0) scale(1)}}
73
- @keyframes mc-demock-drift-a{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(4%,-5%) scale(1.02)}}
74
- @keyframes mc-demock-drift-b{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(-5%,4%) scale(.98)}}
112
+ opacity:.045;transform:translateX(-120%);
113
+ animation:mc-demock-sheen 12.8s ease-in-out infinite}
75
114
  @keyframes mc-demock-sheen{0%,100%{transform:translateX(-120%)}55%,72%{transform:translateX(120%)}}
76
- @media (prefers-reduced-motion:reduce){
77
- .mc-demock-tile{animation:none!important}
78
- .mc-demock-shimmer::after{animation:none!important;opacity:0}
79
- }
80
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
+ }
81
127
 
82
128
  /** Injects the keyframes once per document (idempotent, SSR-safe). */
83
129
  function useMockupStyles() {
@@ -86,109 +132,118 @@ function useMockupStyles() {
86
132
  if (document.getElementById(STYLE_ID)) return
87
133
  const el = document.createElement('style')
88
134
  el.id = STYLE_ID
89
- el.textContent = MOCKUP_CSS
135
+ el.textContent = buildCss()
90
136
  document.head.appendChild(el)
91
137
  }, [])
92
138
  }
93
139
 
94
140
  const tileBase =
95
- 'rounded-lg bg-muted mc-demock-tile mc-demock-shimmer text-foreground overflow-hidden'
96
-
97
- /** A skeleton "chart" tile: a row of bars. */
98
- function ChartGlyph() {
99
- return (
100
- <div className="flex h-full items-end gap-1.5 p-3">
101
- {[6, 10, 7, 12, 9, 11, 8].map((h, i) => (
102
- <div
103
- key={i}
104
- className="w-full rounded-sm bg-foreground/10"
105
- style={{ height: `${h * 8}%` }}
106
- />
107
- ))}
108
- </div>
109
- )
110
- }
141
+ 'rounded-lg border border-border/60 bg-card mc-demock-tile mc-demock-shimmer text-foreground'
111
142
 
112
- /** A skeleton "stat card": label + big number. */
113
- function StatGlyph() {
114
- return (
115
- <div className="flex h-full flex-col justify-center gap-2 p-3">
116
- <div className="h-2 w-1/2 rounded-full bg-foreground/15" />
117
- <div className="h-4 w-2/3 rounded bg-foreground/15" />
118
- </div>
119
- )
120
- }
143
+ type Glyph = 'chart' | 'stat' | 'list' | 'bar'
121
144
 
122
- /** A skeleton "list" tile: stacked rows. */
123
- function ListGlyph() {
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() {
124
148
  return (
125
- <div className="flex h-full flex-col justify-center gap-2 p-3">
126
- <div className="h-2 w-full rounded-full bg-foreground/12" />
127
- <div className="h-2 w-4/5 rounded-full bg-foreground/12" />
128
- <div className="h-2 w-2/3 rounded-full bg-foreground/12" />
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>
129
155
  </div>
130
156
  )
131
157
  }
132
158
 
133
- /** A skeleton "progress bar" tile. */
134
- function BarGlyph() {
159
+ // Bodies copy the anatomy of the matching real renderers (renderers.tsx).
160
+ function TileGlyph({ kind }: { kind: Glyph }) {
135
161
  return (
136
- <div className="flex h-full items-center px-3">
137
- <div className="h-2.5 w-full rounded-full bg-foreground/10">
138
- <div className="h-full w-2/5 rounded-full bg-foreground/20" />
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
+ )}
139
206
  </div>
140
207
  </div>
141
208
  )
142
209
  }
143
210
 
144
- // The board: a 4-col x 4-row grid that fills the container. Each tile declares
145
- // its cell (via inline gridColumn/gridRow) and a motion class. Swap pairs sit in
146
- // adjacent cells and move toward each other, so the eye reads a reshuffle.
147
- type Tile = { area: React.CSSProperties; motion: string; glyph: React.ReactNode }
148
-
149
- const TILES: Tile[] = [
150
- // Big chart (top-left 2x2) — diagonal shuffle with the bottom stat.
151
- { area: { gridColumn: '1 / 3', gridRow: '1 / 3' }, motion: 'mc-demock-shuffleA', glyph: <ChartGlyph /> },
152
- // Horizontal swap pair, top-right.
153
- { area: { gridColumn: '3 / 4', gridRow: '1 / 2' }, motion: 'mc-demock-swapL', glyph: <StatGlyph /> },
154
- { area: { gridColumn: '4 / 5', gridRow: '1 / 2' }, motion: 'mc-demock-swapR', glyph: <StatGlyph /> },
155
- // Vertical swap pair, right column.
156
- { area: { gridColumn: '3 / 5', gridRow: '2 / 3' }, motion: 'mc-demock-swapU', glyph: <ListGlyph /> },
157
- { area: { gridColumn: '3 / 5', gridRow: '3 / 4' }, motion: 'mc-demock-swapD', glyph: <BarGlyph /> },
158
- // Bottom-left cluster: shuffle partner + drifter.
159
- { area: { gridColumn: '1 / 2', gridRow: '3 / 4' }, motion: 'mc-demock-shuffleB', glyph: <StatGlyph /> },
160
- { area: { gridColumn: '2 / 3', gridRow: '3 / 4' }, motion: 'mc-demock-driftA', glyph: <StatGlyph /> },
161
- // Full-width footer bar + trailing stat.
162
- { area: { gridColumn: '1 / 4', gridRow: '4 / 5' }, motion: 'mc-demock-driftB', glyph: <BarGlyph /> },
163
- { area: { gridColumn: '4 / 5', gridRow: '4 / 5' }, motion: 'mc-demock-driftA', glyph: <StatGlyph /> },
164
- ]
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']
165
214
 
166
215
  /**
167
- * Full-bleed animated skeleton of a dashboard whose tiles slide and swap places,
168
- * as if the layout were assembling itself. Purely decorative — always
169
- * `aria-hidden`, no text.
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.
170
225
  */
171
226
  export function DashboardEmptyMockup({ className }: { className?: string }) {
172
227
  useMockupStyles()
228
+ const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), [])
173
229
  return (
174
230
  <div
175
231
  aria-hidden="true"
176
232
  data-testid="dashboard-empty-mockup"
177
- className={cn('mc-demock pointer-events-none h-full w-full select-none', className)}
233
+ className={cn('mc-demock pointer-events-none select-none', className)}
178
234
  >
179
- <div
180
- className="grid h-full w-full gap-3"
181
- style={{
182
- gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
183
- gridTemplateRows: 'repeat(4, minmax(0, 1fr))',
184
- }}
185
- >
186
- {TILES.map((t, i) => (
187
- <div key={i} style={t.area} className={cn(tileBase, t.motion)}>
188
- {t.glyph}
189
- </div>
190
- ))}
191
- </div>
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]} />
245
+ </div>
246
+ ))}
192
247
  </div>
193
248
  )
194
249
  }
@@ -157,11 +157,18 @@ export function DashboardGrid({
157
157
  // Full-bleed: the mockup fills the whole dashboard area, not a
158
158
  // centered illustration. No caption — the animation speaks for
159
159
  // itself (and dodges shipping un-localized copy).
160
- 'min-h-[60vh] w-full rounded-xl border border-dashed border-border/60 p-4',
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',
161
168
  className,
162
169
  )}
163
170
  >
164
- <DashboardEmptyMockup className="h-full min-h-[inherit]" />
171
+ <DashboardEmptyMockup className="h-full" />
165
172
  </div>
166
173
  )
167
174
  }
@@ -1143,24 +1143,30 @@ export function makeDefaultGetDynamicColumns(
1143
1143
  />
1144
1144
  )
1145
1145
  }
1146
- if (
1146
+ const text =
1147
+ value !== null && value !== undefined ? String(value) : '-'
1148
+ // Clamp de texto largo GENÉRICO: un `truncate` en un
1149
+ // span suelto no limita nada dentro de una celda de
1150
+ // tabla (la celda crece con el contenido). Cualquier
1151
+ // texto largo — biografías, notas, direcciones — se
1152
+ // acota a 350px con tooltip del valor completo. La
1153
+ // heurística por longitud cubre lo que la heurística
1154
+ // por nombre ('description') dejaba pasar.
1155
+ const isLongText =
1156
+ text.length > 60 ||
1147
1157
  col.key === 'description' ||
1148
1158
  col.key === 'features' ||
1149
1159
  col.key.includes('description')
1150
- ) {
1160
+ if (isLongText) {
1151
1161
  return (
1152
- <div className="max-w-[350px]" title={String(value)}>
1162
+ <div className="max-w-[350px]" title={text}>
1153
1163
  <span className="truncate font-medium block">
1154
- {value !== null && value !== undefined ? String(value) : '-'}
1164
+ {text}
1155
1165
  </span>
1156
1166
  </div>
1157
1167
  )
1158
1168
  }
1159
- return (
1160
- <span className="truncate font-medium">
1161
- {value !== null && value !== undefined ? String(value) : '-'}
1162
- </span>
1163
- )
1169
+ return <span className="truncate font-medium">{text}</span>
1164
1170
  }
1165
1171
  }
1166
1172
  },
@@ -1286,8 +1286,18 @@ export function DynamicTable({
1286
1286
  onClick={onRowClick ? () => onRowClick(row.original) : undefined}
1287
1287
  >
1288
1288
  {dataCells.map((cell: Cell<any, unknown>) => {
1289
+ // El label humano vive en columnDef.meta.label (lo
1290
+ // adjunta la fábrica de columnas); el header casi
1291
+ // nunca es string (es un componente), así que caer a
1292
+ // column.id mostraba keys crudas ('user.avatar',
1293
+ // 'created_at') en las cards móviles.
1294
+ const cellMeta = cell.column.columnDef.meta as
1295
+ | { label?: string }
1296
+ | undefined
1289
1297
  const header = cell.column.columnDef.header
1290
- const label = typeof header === 'string' ? header : cell.column.id
1298
+ const label =
1299
+ cellMeta?.label ??
1300
+ (typeof header === 'string' ? header : cell.column.id)
1291
1301
  return (
1292
1302
  <div key={cell.id} className='flex items-start justify-between gap-3 text-sm'>
1293
1303
  <span className='shrink-0 text-muted-foreground'>{label}</span>