@asteby/metacore-runtime-react 29.2.4 → 29.2.6

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,31 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 29.2.6
4
+
5
+ ### Patch Changes
6
+
7
+ - b1a9703: feat(barcode-scanner): botón de linterna (torch) cuando el dispositivo lo soporta
8
+
9
+ En ambientes oscuros se puede encender/apagar el flash de la cámara trasera
10
+ desde la barra del overlay. Si el hardware no expone `torch`, el botón no
11
+ aparece. Aplica a POS y formularios (mismo primitivo).
12
+
13
+ ## 29.2.5
14
+
15
+ ### Patch Changes
16
+
17
+ - 428f5b0: fix(dashboard): skeleton vacío = 3 cards estáticas a ancho completo
18
+
19
+ Sin animación de reflow. El stack ya no usa `mc-demock-tile` (absolute), así
20
+ que no se colapsa en una franja chica en móvil / con sidebar.
21
+
22
+ - 48cf4e4: El botón de escaneo también aparece siempre (sin `isCameraScanSupported()`) en
23
+ los campos `dynamic_select` (`dynamic-select-field`) y en el modal de
24
+ crear/editar registro (`dialogs/dynamic-record`), no sólo en el input de texto.
25
+ El renglón de una orden de compra usa `dynamic_select`, así que ahí el icono de
26
+ cámara no aparecía en escritorio pese a declarar `scan`. Ahora los tres
27
+ renderers son consistentes con el POS.
28
+
3
29
  ## 29.2.4
4
30
 
5
31
  ### Patch Changes
@@ -1,4 +1,6 @@
1
1
  import * as React from 'react';
2
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
3
+ export declare function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean;
2
4
  /** Simbolismos de retail que intentamos detectar. */
3
5
  export declare const RETAIL_BARCODE_FORMATS: string[];
4
6
  /** `true` si este navegador puede escanear con la cámara (detector + getUserMedia). */
@@ -1 +1 @@
1
- {"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAqC9B,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BA+IrB;AAED,eAAe,cAAc,CAAA"}
1
+ {"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B,qFAAqF;AACrF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAOtF;AAoDD,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BAgMrB;AAED,eAAe,cAAc,CAAA"}
@@ -1,7 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import * as React from 'react';
3
- import { Camera, ScanLine, X } from 'lucide-react';
3
+ import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react';
4
4
  import { Button } from '@asteby/metacore-ui';
5
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
6
+ export function trackSupportsTorch(track) {
7
+ if (!track || typeof track.getCapabilities !== 'function')
8
+ return false;
9
+ try {
10
+ return !!track.getCapabilities().torch;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function setTrackTorch(track, on) {
17
+ try {
18
+ await track.applyConstraints({
19
+ advanced: [{ torch: on }],
20
+ });
21
+ return true;
22
+ }
23
+ catch {
24
+ // Algunos WebViews fallan con advanced[] — reintento plano.
25
+ try {
26
+ await track.applyConstraints({ torch: on });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ }
5
34
  /** Simbolismos de retail que intentamos detectar. */
6
35
  export const RETAIL_BARCODE_FORMATS = [
7
36
  'ean_13',
@@ -71,19 +100,38 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
71
100
  const lastHitRef = React.useRef({ code: '', at: 0 });
72
101
  const [error, setError] = React.useState(null);
73
102
  const [ready, setReady] = React.useState(false);
103
+ const [torchAvailable, setTorchAvailable] = React.useState(false);
104
+ const [torchOn, setTorchOn] = React.useState(false);
74
105
  const beep = useScanBeep();
75
106
  const stop = React.useCallback(() => {
76
107
  if (rafRef.current != null)
77
108
  cancelAnimationFrame(rafRef.current);
78
109
  rafRef.current = null;
110
+ const track = streamRef.current?.getVideoTracks()[0];
111
+ if (track && trackSupportsTorch(track)) {
112
+ void setTrackTorch(track, false);
113
+ }
79
114
  streamRef.current?.getTracks().forEach((t) => t.stop());
80
115
  streamRef.current = null;
81
116
  setReady(false);
117
+ setTorchAvailable(false);
118
+ setTorchOn(false);
82
119
  }, []);
120
+ const toggleTorch = React.useCallback(async () => {
121
+ const track = streamRef.current?.getVideoTracks()[0];
122
+ if (!track || !trackSupportsTorch(track))
123
+ return;
124
+ const next = !torchOn;
125
+ const ok = await setTrackTorch(track, next);
126
+ if (ok)
127
+ setTorchOn(next);
128
+ }, [torchOn]);
83
129
  React.useEffect(() => {
84
130
  if (!open)
85
131
  return;
86
132
  setError(null);
133
+ setTorchAvailable(false);
134
+ setTorchOn(false);
87
135
  const Ctor = getDetectorCtor();
88
136
  if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
89
137
  setError('Tu navegador no soporta escaneo por cámara. Usá un lector físico (escribe en el campo) o escribí el código a mano.');
@@ -132,6 +180,8 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
132
180
  return;
133
181
  }
134
182
  streamRef.current = stream;
183
+ const videoTrack = stream.getVideoTracks()[0];
184
+ setTorchAvailable(trackSupportsTorch(videoTrack));
135
185
  const video = videoRef.current;
136
186
  if (video) {
137
187
  video.srcObject = stream;
@@ -155,6 +205,12 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
155
205
  if (!open)
156
206
  return null;
157
207
  const posClass = position === 'fixed' ? 'fixed' : 'absolute';
158
- return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsx(Button, { variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 text-center text-sm text-white/90", children: ready ? hint : 'Abriendo cámara…' })] })) })] }));
208
+ return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsxs("div", { className: "flex items-center gap-1", children: [torchAvailable && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: torchOn
209
+ ? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
210
+ : 'text-white hover:bg-white/10 hover:text-white', onClick: () => void toggleTorch(), "aria-label": torchOn ? 'Apagar linterna' : 'Encender linterna', "aria-pressed": torchOn, title: torchOn ? 'Apagar linterna' : 'Encender linterna', children: torchOn ? (_jsx(Flashlight, { className: "size-5" })) : (_jsx(FlashlightOff, { className: "size-5" })) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90", children: !ready
211
+ ? 'Abriendo cámara…'
212
+ : torchAvailable
213
+ ? `${hint} · tocá la linterna si está oscuro`
214
+ : hint })] })) })] }));
159
215
  }
160
216
  export default BarcodeScanner;
@@ -24,15 +24,12 @@ export declare function computeLayoutRects(layout: LayoutSpec, gap?: number): Mo
24
24
  /** The precomputed rects for all four layouts (exported for the overlap test). */
25
25
  export declare function mockupLayoutRects(gap?: number): MockupRect[][];
26
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.
27
+ * Full-bleed skeleton dashboard: 3 static cards stacked full-width.
28
+ * No reflow animation size and layout stability matter more than motion.
29
+ * Purely decorative — always `aria-hidden`, no text.
30
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.
31
+ * The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
32
+ * the overlap unit test / future reuse; the visible UI is the stack only.
36
33
  */
37
34
  export declare function DashboardEmptyMockup({ className }: {
38
35
  className?: string;
@@ -1 +1 @@
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,qBAyCzE"}
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;AA0HD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,qBAuBzE"}
@@ -112,7 +112,9 @@ function useMockupStyles() {
112
112
  document.head.appendChild(el);
113
113
  }, []);
114
114
  }
115
- const tileBase = 'rounded-lg border border-border/60 bg-card mc-demock-tile mc-demock-shimmer text-foreground';
115
+ // Static stack cards never `mc-demock-tile` (that class is position:absolute
116
+ // for the old animated grid and collapsed cards into a tiny top-left strip).
117
+ const stackTileBase = 'relative w-full rounded-xl border border-border/60 bg-card mc-demock-shimmer text-foreground';
116
118
  // Header shared by every card kind — icon chip + title bar + subtitle bar,
117
119
  // mirroring the real WidgetCard chrome (widgets/widget-card.tsx).
118
120
  function SkeletonHeader() {
@@ -122,28 +124,17 @@ function SkeletonHeader() {
122
124
  function TileGlyph({ kind }) {
123
125
  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
126
  }
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'];
128
127
  /**
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.
128
+ * Full-bleed skeleton dashboard: 3 static cards stacked full-width.
129
+ * No reflow animation size and layout stability matter more than motion.
130
+ * Purely decorative — always `aria-hidden`, no text.
132
131
  *
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.
132
+ * The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
133
+ * the overlap unit test / future reuse; the visible UI is the stack only.
138
134
  */
139
135
  export function DashboardEmptyMockup({ className }) {
140
136
  useMockupStyles();
141
- const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), []);
142
- return (_jsxs("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('pointer-events-none select-none', className), children: [_jsx("div", { className: "flex min-h-full flex-col gap-3 sm:hidden", children: MOBILE_TILE_KINDS.map((kind, i) => (_jsx("div", { className: cn(tileBase, 'mc-demock-shimmer relative min-h-[132px] flex-1'), children: _jsx(TileGlyph, { kind: kind }) }, i))) }), _jsx("div", { className: "mc-demock hidden h-full sm:block", 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))) })] }));
137
+ return (_jsx("div", { "aria-hidden": "true", "data-testid": "dashboard-empty-mockup", className: cn('pointer-events-none w-full select-none', className), children: _jsx("div", { className: "flex w-full flex-col gap-4", "data-testid": "dashboard-empty-mockup-stack", children: STACK_TILE_KINDS.map((kind, i) => (_jsx("div", { className: cn(stackTileBase, 'min-h-[200px]'), children: _jsx(TileGlyph, { kind: kind }) }, i))) }) }));
146
138
  }
147
- // En móvil mostramos 3 cards apiladas (stat + chart + list) a ancho completo
148
- // suficiente para comunicar "tablero cargando/ vacío" sin apretar 6 tiles.
149
- const MOBILE_TILE_KINDS = ['stat', 'chart', 'list'];
139
+ // 3 cards a ancho completo stat + chart + list. Sin animación de reflow.
140
+ const STACK_TILE_KINDS = ['stat', 'chart', 'list'];
@@ -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,qBAuJpB"}
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"}
@@ -121,21 +121,10 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
121
121
  // Global empty state (no widgets at all / none visible after gating).
122
122
  if (visibleGroups.length === 0) {
123
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
- // No dashed border / padding wrapper: the skeleton tiles ARE
135
- // cards (same anatomy as the real widgets), so the mockup
136
- // should float exactly like the loaded dashboard, not sit
137
- // inside a boxed placeholder. Keep only the definite height.
138
- 'h-[clamp(420px,60vh,720px)] w-full', className), children: _jsx(DashboardEmptyMockup, { className: "h-full" }) }));
124
+ // Full-bleed stack of static skeleton cards (no dashed box,
125
+ // no caption). Height follows the 3 tall cards — no absolute
126
+ // %-height grid that needed a clamp wrapper.
127
+ 'w-full', className), children: _jsx(DashboardEmptyMockup, {}) }));
139
128
  }
140
129
  return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
141
130
  // Masonry: balanced CSS columns. Cards take their natural height
@@ -18,7 +18,7 @@ import { toast } from 'sonner';
18
18
  import { format, parseISO } from 'date-fns';
19
19
  import { es } from 'date-fns/locale';
20
20
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react';
21
- import { BarcodeScanner, isCameraScanSupported } from '../barcode-scanner';
21
+ import { BarcodeScanner } from '../barcode-scanner';
22
22
  import { useApi } from '../api-context';
23
23
  import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error';
24
24
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
@@ -1026,9 +1026,11 @@ export function EditField({ field, value, onChange, record }) {
1026
1026
  */
1027
1027
  function ScannableRecordInput({ field, value, onChange, inputType, }) {
1028
1028
  const [scanOpen, setScanOpen] = useState(false);
1029
+ // El botón de escaneo aparece siempre que el campo declara `scan` (como el
1030
+ // POS), sin esconderse por `isCameraScanSupported()`. El BarcodeScanner ya
1031
+ // muestra un mensaje amable cuando el navegador no tiene cámara.
1029
1032
  const scanEnabled = !!(field.scan ??
1030
- field.scannable) &&
1031
- isCameraScanSupported();
1033
+ field.scannable);
1032
1034
  const input = (_jsx(Input, { type: inputType, value: value ?? '', onChange: (e) => onChange(inputType === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value), placeholder: field.placeholder }));
1033
1035
  if (!scanEnabled)
1034
1036
  return input;
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA6BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAWD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,GACnB,EAAE,uBAAuB,+BAsQzB;AAED,eAAe,kBAAkB,CAAA"}
1
+ {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA6BA;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAWD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,GACnB,EAAE,uBAAuB,+BAyQzB;AAED,eAAe,kBAAkB,CAAA"}
@@ -26,7 +26,7 @@ import { useEffect, useRef, useState } from 'react';
26
26
  import { Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
27
27
  import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react';
28
28
  import { resolveColorCss } from '@asteby/metacore-ui/lib';
29
- import { BarcodeScanner, isCameraScanSupported } from './barcode-scanner';
29
+ import { BarcodeScanner } from './barcode-scanner';
30
30
  import { DynamicIcon, isLucideIconName } from './dynamic-icon';
31
31
  import { useOptionsResolver } from './use-options-resolver';
32
32
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema';
@@ -98,10 +98,13 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
98
98
  const [search, setSearch] = useState('');
99
99
  const [scanOpen, setScanOpen] = useState(false);
100
100
  const debounced = useDebounced(search, 250);
101
- // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`) y sólo
102
- // si el navegador soporta BarcodeDetector. Un código escaneado alimenta la
103
- // búsqueda y abre el picker para elegir la referencia sin tipear el UUID.
104
- const scanEnabled = !!(field.scan ?? field.scannable) && isCameraScanSupported();
101
+ // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
102
+ // botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
103
+ // esconderse por `isCameraScanSupported()` en escritorio el botón no salía
104
+ // aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
105
+ // cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
106
+ // picker para elegir la referencia sin tipear el UUID.
107
+ const scanEnabled = !!(field.scan ?? field.scannable);
105
108
  const handleScanDetected = (code) => {
106
109
  setSearch(code);
107
110
  setOpen(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "29.2.4",
3
+ "version": "29.2.6",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, it, expect, afterEach } from 'vitest'
3
- import { isCameraScanSupported, RETAIL_BARCODE_FORMATS } from '../barcode-scanner'
3
+ import {
4
+ isCameraScanSupported,
5
+ RETAIL_BARCODE_FORMATS,
6
+ trackSupportsTorch,
7
+ } from '../barcode-scanner'
4
8
 
5
9
  describe('barcode-scanner primitive', () => {
6
10
  afterEach(() => {
@@ -25,4 +29,18 @@ describe('barcode-scanner primitive', () => {
25
29
  const hasGUM = !!navigator.mediaDevices?.getUserMedia
26
30
  expect(isCameraScanSupported()).toBe(hasGUM)
27
31
  })
32
+
33
+ it('trackSupportsTorch lee capabilities.torch del track', () => {
34
+ expect(trackSupportsTorch(null)).toBe(false)
35
+ expect(
36
+ trackSupportsTorch({
37
+ getCapabilities: () => ({}),
38
+ } as MediaStreamTrack),
39
+ ).toBe(false)
40
+ expect(
41
+ trackSupportsTorch({
42
+ getCapabilities: () => ({ torch: true }),
43
+ } as unknown as MediaStreamTrack),
44
+ ).toBe(true)
45
+ })
28
46
  })
@@ -156,16 +156,16 @@ describe('DashboardGrid render', () => {
156
156
  expect(screen.getByTestId('dashboard-empty')).toBeTruthy()
157
157
  })
158
158
 
159
- it('renders the animated mockup (decorative) in the empty state', () => {
159
+ it('renders the static full-width skeleton stack (decorative) in the empty state', () => {
160
160
  render(<DashboardGrid widgets={[]} loadData={loaderOf({})} />)
161
161
  const mock = screen.getByTestId('dashboard-empty-mockup')
162
162
  expect(mock).toBeTruthy()
163
+ expect(screen.getByTestId('dashboard-empty-mockup-stack')).toBeTruthy()
163
164
  // Purely decorative → hidden from the a11y tree.
164
165
  expect(mock.getAttribute('aria-hidden')).toBe('true')
165
- // Keyframes are injected once into the document head.
166
+ // Shimmer styles still inject once (no reflow keyframes required).
166
167
  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).
168
+ // No caption text — avoids shipping un-localized copy from the package.
169
169
  expect(screen.queryByText(/dashboard/i)).toBeNull()
170
170
  })
171
171
 
@@ -1,7 +1,38 @@
1
1
  import * as React from 'react'
2
- import { Camera, ScanLine, X } from 'lucide-react'
2
+ import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react'
3
3
  import { Button } from '@asteby/metacore-ui'
4
4
 
5
+ /** `torch` aún no está en todas las libs de TS — extendemos el contrato mínimo. */
6
+ type TorchCapabilities = MediaTrackCapabilities & { torch?: boolean }
7
+ type TorchConstraintSet = MediaTrackConstraintSet & { torch?: boolean }
8
+
9
+ /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
10
+ export function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean {
11
+ if (!track || typeof track.getCapabilities !== 'function') return false
12
+ try {
13
+ return !!(track.getCapabilities() as TorchCapabilities).torch
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ async function setTrackTorch(track: MediaStreamTrack, on: boolean): Promise<boolean> {
20
+ try {
21
+ await track.applyConstraints({
22
+ advanced: [{ torch: on } as TorchConstraintSet],
23
+ })
24
+ return true
25
+ } catch {
26
+ // Algunos WebViews fallan con advanced[] — reintento plano.
27
+ try {
28
+ await track.applyConstraints({ torch: on } as MediaTrackConstraints)
29
+ return true
30
+ } catch {
31
+ return false
32
+ }
33
+ }
34
+ }
35
+
5
36
  /**
6
37
  * BarcodeScanner — primitivo REUSABLE de escaneo por cámara para todo el
7
38
  * ecosistema (POS, formularios declarativos, cualquier addon). Sin dependencias
@@ -141,19 +172,37 @@ export function BarcodeScanner({
141
172
  const lastHitRef = React.useRef<{ code: string; at: number }>({ code: '', at: 0 })
142
173
  const [error, setError] = React.useState<string | null>(null)
143
174
  const [ready, setReady] = React.useState(false)
175
+ const [torchAvailable, setTorchAvailable] = React.useState(false)
176
+ const [torchOn, setTorchOn] = React.useState(false)
144
177
  const beep = useScanBeep()
145
178
 
146
179
  const stop = React.useCallback(() => {
147
180
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
148
181
  rafRef.current = null
182
+ const track = streamRef.current?.getVideoTracks()[0]
183
+ if (track && trackSupportsTorch(track)) {
184
+ void setTrackTorch(track, false)
185
+ }
149
186
  streamRef.current?.getTracks().forEach((t) => t.stop())
150
187
  streamRef.current = null
151
188
  setReady(false)
189
+ setTorchAvailable(false)
190
+ setTorchOn(false)
152
191
  }, [])
153
192
 
193
+ const toggleTorch = React.useCallback(async () => {
194
+ const track = streamRef.current?.getVideoTracks()[0]
195
+ if (!track || !trackSupportsTorch(track)) return
196
+ const next = !torchOn
197
+ const ok = await setTrackTorch(track, next)
198
+ if (ok) setTorchOn(next)
199
+ }, [torchOn])
200
+
154
201
  React.useEffect(() => {
155
202
  if (!open) return
156
203
  setError(null)
204
+ setTorchAvailable(false)
205
+ setTorchOn(false)
157
206
 
158
207
  const Ctor = getDetectorCtor()
159
208
  if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
@@ -205,6 +254,8 @@ export function BarcodeScanner({
205
254
  return
206
255
  }
207
256
  streamRef.current = stream
257
+ const videoTrack = stream.getVideoTracks()[0]
258
+ setTorchAvailable(trackSupportsTorch(videoTrack))
208
259
  const video = videoRef.current
209
260
  if (video) {
210
261
  video.srcObject = stream
@@ -239,15 +290,40 @@ export function BarcodeScanner({
239
290
  <ScanLine className="size-5" />
240
291
  {title}
241
292
  </div>
242
- <Button
243
- variant="ghost"
244
- size="icon"
245
- className="text-white hover:bg-white/10 hover:text-white"
246
- onClick={onClose}
247
- aria-label="Cerrar escáner"
248
- >
249
- <X className="size-5" />
250
- </Button>
293
+ <div className="flex items-center gap-1">
294
+ {torchAvailable && (
295
+ <Button
296
+ type="button"
297
+ variant="ghost"
298
+ size="icon"
299
+ className={
300
+ torchOn
301
+ ? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
302
+ : 'text-white hover:bg-white/10 hover:text-white'
303
+ }
304
+ onClick={() => void toggleTorch()}
305
+ aria-label={torchOn ? 'Apagar linterna' : 'Encender linterna'}
306
+ aria-pressed={torchOn}
307
+ title={torchOn ? 'Apagar linterna' : 'Encender linterna'}
308
+ >
309
+ {torchOn ? (
310
+ <Flashlight className="size-5" />
311
+ ) : (
312
+ <FlashlightOff className="size-5" />
313
+ )}
314
+ </Button>
315
+ )}
316
+ <Button
317
+ type="button"
318
+ variant="ghost"
319
+ size="icon"
320
+ className="text-white hover:bg-white/10 hover:text-white"
321
+ onClick={onClose}
322
+ aria-label="Cerrar escáner"
323
+ >
324
+ <X className="size-5" />
325
+ </Button>
326
+ </div>
251
327
  </div>
252
328
 
253
329
  {/* Área de cámara */}
@@ -269,8 +345,12 @@ export function BarcodeScanner({
269
345
  <span className="bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" />
270
346
  </div>
271
347
  </div>
272
- <p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/90">
273
- {ready ? hint : 'Abriendo cámara…'}
348
+ <p className="absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90">
349
+ {!ready
350
+ ? 'Abriendo cámara…'
351
+ : torchAvailable
352
+ ? `${hint} · tocá la linterna si está oscuro`
353
+ : hint}
274
354
  </p>
275
355
  </>
276
356
  )}
@@ -137,8 +137,10 @@ function useMockupStyles() {
137
137
  }, [])
138
138
  }
139
139
 
140
- const tileBase =
141
- 'rounded-lg border border-border/60 bg-card mc-demock-tile mc-demock-shimmer text-foreground'
140
+ // Static stack cards — never `mc-demock-tile` (that class is position:absolute
141
+ // for the old animated grid and collapsed cards into a tiny top-left strip).
142
+ const stackTileBase =
143
+ 'relative w-full rounded-xl border border-border/60 bg-card mc-demock-shimmer text-foreground'
142
144
 
143
145
  type Glyph = 'chart' | 'stat' | 'list' | 'bar'
144
146
 
@@ -208,64 +210,38 @@ function TileGlyph({ kind }: { kind: Glyph }) {
208
210
  )
209
211
  }
210
212
 
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']
214
-
215
213
  /**
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.
214
+ * Full-bleed skeleton dashboard: 3 static cards stacked full-width.
215
+ * No reflow animation size and layout stability matter more than motion.
216
+ * Purely decorative — always `aria-hidden`, no text.
219
217
  *
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.
218
+ * The layout-math helpers above (`computeLayoutRects`, etc.) stay exported for
219
+ * the overlap unit test / future reuse; the visible UI is the stack only.
225
220
  */
226
221
  export function DashboardEmptyMockup({ className }: { className?: string }) {
227
222
  useMockupStyles()
228
- const layoutA = React.useMemo(() => computeLayoutRects(MOCKUP_LAYOUTS[0]), [])
229
223
  return (
230
224
  <div
231
225
  aria-hidden="true"
232
226
  data-testid="dashboard-empty-mockup"
233
- className={cn('pointer-events-none select-none', className)}
227
+ className={cn('pointer-events-none w-full select-none', className)}
234
228
  >
235
- {/* Móvil (<sm): las 3 columnas absolutas quedan angostas y apretadas
236
- en un teléfono. Un STACK vertical de cards a ancho completo se lee
237
- claro. Toggle por CSS (sin JS, SSR-safe): el reflow animado sólo
238
- aparece de sm hacia arriba, donde hay ancho para las 3 columnas. */}
239
- <div className="flex min-h-full flex-col gap-3 sm:hidden">
240
- {MOBILE_TILE_KINDS.map((kind, i) => (
229
+ <div
230
+ className="flex w-full flex-col gap-4"
231
+ data-testid="dashboard-empty-mockup-stack"
232
+ >
233
+ {STACK_TILE_KINDS.map((kind, i) => (
241
234
  <div
242
235
  key={i}
243
- className={cn(tileBase, 'mc-demock-shimmer relative min-h-[132px] flex-1')}
236
+ className={cn(stackTileBase, 'min-h-[200px]')}
244
237
  >
245
238
  <TileGlyph kind={kind} />
246
239
  </div>
247
240
  ))}
248
241
  </div>
249
-
250
- {/* Tablet/escritorio: el mockup de 3 columnas que reorganiza en bucle. */}
251
- <div className="mc-demock hidden h-full sm:block">
252
- {layoutA.map((r, i) => (
253
- <div
254
- key={i}
255
- data-mockup-tile="tile"
256
- className={cn(tileBase, `mc-demock-t${i}`)}
257
- // Base position = layout A, so reduced-motion (animation off)
258
- // shows the full static composition.
259
- style={{ left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` }}
260
- >
261
- <TileGlyph kind={TILE_KINDS[i]} />
262
- </div>
263
- ))}
264
- </div>
265
242
  </div>
266
243
  )
267
244
  }
268
245
 
269
- // En móvil mostramos 3 cards apiladas (stat + chart + list) a ancho completo
270
- // suficiente para comunicar "tablero cargando/ vacío" sin apretar 6 tiles.
271
- const MOBILE_TILE_KINDS: Glyph[] = ['stat', 'chart', 'list']
246
+ // 3 cards a ancho completo stat + chart + list. Sin animación de reflow.
247
+ const STACK_TILE_KINDS: Glyph[] = ['stat', 'chart', 'list']
@@ -154,25 +154,14 @@ export function DashboardGrid({
154
154
  <div
155
155
  data-testid="dashboard-empty"
156
156
  className={cn(
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
- // No dashed border / padding wrapper: the skeleton tiles ARE
168
- // cards (same anatomy as the real widgets), so the mockup
169
- // should float exactly like the loaded dashboard, not sit
170
- // inside a boxed placeholder. Keep only the definite height.
171
- 'h-[clamp(420px,60vh,720px)] w-full',
157
+ // Full-bleed stack of static skeleton cards (no dashed box,
158
+ // no caption). Height follows the 3 tall cards — no absolute
159
+ // %-height grid that needed a clamp wrapper.
160
+ 'w-full',
172
161
  className,
173
162
  )}
174
163
  >
175
- <DashboardEmptyMockup className="h-full" />
164
+ <DashboardEmptyMockup />
176
165
  </div>
177
166
  )
178
167
  }
@@ -47,7 +47,7 @@ import { toast } from 'sonner'
47
47
  import { format, parseISO } from 'date-fns'
48
48
  import { es } from 'date-fns/locale'
49
49
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
50
- import { BarcodeScanner, isCameraScanSupported } from '../barcode-scanner'
50
+ import { BarcodeScanner } from '../barcode-scanner'
51
51
  import { useApi } from '../api-context'
52
52
  import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error'
53
53
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
@@ -1848,10 +1848,13 @@ function ScannableRecordInput({
1848
1848
  inputType: string
1849
1849
  }) {
1850
1850
  const [scanOpen, setScanOpen] = useState(false)
1851
- const scanEnabled =
1852
- !!((field as { scan?: boolean; scannable?: boolean }).scan ??
1853
- (field as { scan?: boolean; scannable?: boolean }).scannable) &&
1854
- isCameraScanSupported()
1851
+ // El botón de escaneo aparece siempre que el campo declara `scan` (como el
1852
+ // POS), sin esconderse por `isCameraScanSupported()`. El BarcodeScanner ya
1853
+ // muestra un mensaje amable cuando el navegador no tiene cámara.
1854
+ const scanEnabled = !!(
1855
+ (field as { scan?: boolean; scannable?: boolean }).scan ??
1856
+ (field as { scan?: boolean; scannable?: boolean }).scannable
1857
+ )
1855
1858
  const input = (
1856
1859
  <Input
1857
1860
  type={inputType}
@@ -37,7 +37,7 @@ import {
37
37
  } from '@asteby/metacore-ui/primitives'
38
38
  import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react'
39
39
  import { resolveColorCss } from '@asteby/metacore-ui/lib'
40
- import { BarcodeScanner, isCameraScanSupported } from './barcode-scanner'
40
+ import { BarcodeScanner } from './barcode-scanner'
41
41
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
42
42
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
43
43
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
@@ -193,10 +193,13 @@ export function DynamicSelectField({
193
193
  const [scanOpen, setScanOpen] = useState(false)
194
194
  const debounced = useDebounced(search, 250)
195
195
 
196
- // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`) y sólo
197
- // si el navegador soporta BarcodeDetector. Un código escaneado alimenta la
198
- // búsqueda y abre el picker para elegir la referencia sin tipear el UUID.
199
- const scanEnabled = !!(field.scan ?? field.scannable) && isCameraScanSupported()
196
+ // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
197
+ // botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
198
+ // esconderse por `isCameraScanSupported()` en escritorio el botón no salía
199
+ // aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
200
+ // cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
201
+ // picker para elegir la referencia sin tipear el UUID.
202
+ const scanEnabled = !!(field.scan ?? field.scannable)
200
203
  const handleScanDetected = (code: string) => {
201
204
  setSearch(code)
202
205
  setOpen(true)