// Primitivas UI accesibles compartidas por varias pantallas.
//
// Task 4.1 añade `Menu`, el primer inquilino de este fichero. Task 4.2 añade
// `ActionSheet`. Task 4.4 añade `ContextMenuTarget` (tareas posteriores de la
// Fase 4 podrán seguir añadiendo aquí `Stepper`, …). Su COMPORTAMIENTO está
// portado de `Menu`/`ActionSheet`/`ContextMenuTarget` en
// `feat/kraft-nativo`'s `src/ui.jsx` (posicionamiento
// bajo el ancla con flip si no cabe, seguimiento del ancla mientras está
// abierto, cierre solo ante un scroll real del usuario, Esc/Tab que no se
// propagan a una hoja contenedora, devolución de foco al ancla, roles ARIA
// `menu`/`menuitem*`, portal para no quedar recortado por un
// `overflow:hidden` ajeno) pero reescrito con los tokens visuales de `main`
// (Kraft clásico): fondo `c.bgCard`/`c.bgSheet`, borde `c.border`,
// `theme.radius.lg`/`xl`, sombra `c.shadow`. Sin `backdrop-filter` ni
// material translúcido — eso pertenece al tema Kraft nuevo, no a este
// rediseño (ver docs/superpowers/specs/2026-09-15-diseno-original-mejorado.md,
// «Regla general»).
//
// MenuItem = { label, icon?, checked?, radio?, disabled?, destructive?, onSelect() }
// - `checked === undefined` → role="menuitem" (acción simple).
// - `checked` definido + `radio: true` → role="menuitemradio".
// - `checked` definido sin `radio` → role="menuitemcheckbox".
// - `destructive` pinta el texto en `c.danger`.
//
// ActionSheetAction = { label, icon?, destructive?, onSelect() }
// - Sin `disabled`: la pantalla que abre el `ActionSheet` decide si la
//   acción destructiva cabe en `actions` (p. ej. omitiéndola cuando no se
//   puede borrar la última lista) en vez de pintarla deshabilitada — así
//   el array de acciones siempre representa lo que de verdad se puede hacer.

// Control +/- (Task 5.1): reemplaza la entrada de texto libre para la
// cantidad de un producto. Portado de `Stepper` en `feat/kraft-nativo`'s
// `src/ui.jsx` (mismo API: `value`/`onChange`/`min`/`max`/`step`/`label`,
// para que la Task 5.2 —unidades, `step=0.5`/`50`— no tenga que volver a
// tocar este componente) pero reescrito con los tokens de `main`: sin
// `c.surface2` (no existe aquí) se usa `c.bgAlt`/`c.border` como panel
// neutro, y sin `theme.type.headline` el número se pinta con
// `theme.fontMono` + `rem()` (Task 4.5) en vez de un token tipográfico que
// esta rama no tiene. No hay `IconButton` en este fichero (kraft-nativo sí
// lo tiene) — aquí cada botón es un `<button>` con zona táctil 44×44 (visual
// 32×32, margin negativo cancela el resto, mismo patrón que ya usan las
// filas de producto), con háptica `selection()` en cada cambio (guardada
// tras `window.SLMotion`, igual que hace `ContextMenuTarget.openMenu`).
// `decreaseLabel`/`increaseLabel` son opcionales: cuando el llamante conoce
// el idioma activo (p. ej. `EditItemSheet`, que tiene `app.t`) debe pasarlos
// traducidos; si no se dan, caen a un rótulo genérico derivado de `label`
// para que el botón siga teniendo nombre accesible.
function Stepper({ theme, value, onChange, min = 0, max = 999, step = 1, label, decreaseLabel, increaseLabel }) {
  const c = theme.c;
  const n = Number(value) || 0;
  // `step<1` (p. ej. 0.5 para kg/L en la Task 5.2) se formatea con un
  // decimal y coma, convención española — esta rama es español-primero.
  const fmt = (v) => (step < 1 ? v.toFixed(1).replace('.', ',') : String(v));
  const set = (v) => {
    const r = Math.min(max, Math.max(min, Math.round(v / step) * step));
    if (r === n) return;
    if (window.SLMotion) SLMotion.haptic.selection();
    onChange(r);
  };
  const atMin = n <= min, atMax = n >= max;
  const btnStyle = (disabled) => ({
    width: 44, height: 44, margin: -6, borderRadius: theme.radius.pill,
    border: 'none', background: 'transparent',
    color: disabled ? c.inkMuted : c.ink,
    cursor: disabled ? 'default' : 'pointer',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    flexShrink: 0, opacity: disabled ? 0.4 : 1,
  });
  return (
    <div role="group" aria-label={label} style={{
      display: 'inline-flex', alignItems: 'center', gap: 4,
      background: c.bgAlt, border: `1px solid ${c.border}`,
      borderRadius: theme.radius.pill, padding: '0 6px',
    }}>
      <button type="button" data-testid="stepper-decrease"
        aria-label={decreaseLabel || `${label || ''} -`}
        onClick={() => set(n - step)} disabled={atMin} style={btnStyle(atMin)}>
        <Icon name="minus" size={16} stroke={2.4} />
      </button>
      <span aria-live="polite" data-testid="stepper-value" style={{
        minWidth: 30, textAlign: 'center', fontVariantNumeric: 'tabular-nums',
        fontSize: rem(15), fontWeight: 700, fontFamily: theme.fontMono, color: c.ink,
      }}>{fmt(n)}</span>
      <button type="button" data-testid="stepper-increase"
        aria-label={increaseLabel || `${label || ''} +`}
        onClick={() => set(n + step)} disabled={atMax} style={btnStyle(atMax)}>
        <Icon name="plus" size={16} stroke={2.4} />
      </button>
    </div>
  );
}

// Selector de lo enfocable dentro del ancla (para devolver el foco a su
// primer control si el propio ancla no es enfocable, p. ej. un envoltorio).
const FOCUSABLE_SEL = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';

// A dónde vuelve el foco al cerrarse el menú: lo que lo tenía al abrirse, si
// seguía dentro del ancla; si no, el propio ancla si es enfocable, o su
// primer control enfocable. `null` si el ancla ya no está en el DOM.
function menuReturnTarget(anchor, before) {
  if (!anchor || !anchor.isConnected) return null;
  if (before && before !== document.body && before.isConnected && anchor.contains(before)) return before;
  if (anchor.matches && anchor.matches(FOCUSABLE_SEL)) return anchor;
  return anchor.querySelector ? anchor.querySelector(FOCUSABLE_SEL) : null;
}

// Menú anclado: crece bajo el botón que lo abre (o encima si no cabe), se
// cierra al elegir una opción, al tocar fuera, con Esc o con un scroll real,
// y se maneja con flechas/Home/End. `anchorRef` es el `ref` del elemento que
// lo abre — Menu le devuelve el foco al cerrarse.
function Menu({ theme, open, anchorRef, align = 'start', sections, onClose, label, testid = 'menu' }) {
  const c = theme.c;
  const boxRef = React.useRef(null);
  const [pos, setPos] = React.useState(null);
  const [hoverLabel, setHoverLabel] = React.useState(null);
  // Espejo síncrono: una vez elegido un item, el menú entra en su cierre y
  // ya no debe reaccionar a más teclas (p. ej. un Esc no debe volver a
  // intentar cerrarlo, ni una flecha moverse por una lista que va a desaparecer).
  const closingRef = React.useRef(false);
  const returnRef = React.useRef(null);

  // Portal a #root, igual que ya hacen `CategoryPicker`/`Sheet` en
  // smart-input.jsx: el wrapper que abre el menú puede tener su propio
  // `overflow:hidden` (una pantalla con scroll, una fila), y sin portal el
  // menú quedaría recortado por él.
  const layerEl = () => document.getElementById('root') || document.body;

  // Posición bajo el ancla (encima si no cabe), relativa a la capa del menú;
  // `r` es el rectángulo del ancla en pantalla.
  const placeAt = (r) => {
    const layer = layerEl();
    const base = layer.getBoundingClientRect();
    const w = Math.min(280, base.width - 32);
    let left = align === 'end' ? r.right - base.left - w : r.left - base.left;
    left = Math.max(16, Math.min(left, base.width - 16 - w));
    const count = sections.reduce((n, s) => n + s.length, 0);
    const estH = count * 44 + (sections.length - 1) * 8;
    const below = r.bottom - base.top + 6;
    const up = below + estH > base.height - 16;
    return { left, w, top: up ? undefined : below, bottom: up ? base.bottom - r.top + 6 : undefined };
  };

  React.useLayoutEffect(() => {
    if (!open || !anchorRef || !anchorRef.current) return;
    setPos(placeAt(anchorRef.current.getBoundingClientRect()));
    // Solo recalcula en la apertura; mientras está abierto, el efecto de
    // abajo se encarga de seguir/cerrar según se mueva el ancla.
  }, [open]);

  React.useEffect(() => {
    if (!open) return;
    closingRef.current = false;
    const anchor = anchorRef && anchorRef.current;

    // Seguimiento del ancla mientras el menú está abierto: la hoja/fila que
    // lo contiene puede desplazarse (se asienta al abrirse, o el teclado la
    // empuja) sin que eso cuente como un scroll del usuario — el menú
    // simplemente la sigue. Un scroll real (evento `scroll` fuera del menú)
    // sí lo cierra, pero solo una vez el ancla lleva ~20 fotogramas quieta
    // (se considera "asentada"); un salto de un único fotograma no cuenta,
    // se confirma en el siguiente.
    let raf = 0, frames = 0, streak = 0, settled = !anchor, scrolled = false, confirming = false;
    const relOf = (r) => {
      const L = layerEl().getBoundingClientRect();
      return { top: r.top - L.top, left: r.left - L.left };
    };
    let placed = anchor ? relOf(anchor.getBoundingClientRect()) : null;
    let prev = placed;
    const closeNow = () => { cancelAnimationFrame(raf); raf = 0; onClose(); };
    const track = () => {
      raf = requestAnimationFrame(track);
      if (!anchor.isConnected) { closeNow(); return; }
      const r = anchor.getBoundingClientRect();
      if (r.bottom <= 0 || r.top >= window.innerHeight || r.right <= 0 || r.left >= window.innerWidth) { closeNow(); return; }
      const p = relOf(r);
      frames += 1;
      streak = Math.abs(p.top - prev.top) < 0.5 && Math.abs(p.left - prev.left) < 0.5 ? streak + 1 : 0;
      prev = p;
      if (!settled && (streak >= 20 || frames > 150)) settled = true;
      const byUser = scrolled;
      scrolled = false;
      const dTop = Math.abs(p.top - placed.top), dLeft = Math.abs(p.left - placed.left);
      if (dTop <= 0.5 && dLeft <= 0.5) { confirming = false; return; }
      if (settled && (dTop > 2 || dLeft > 2) && (byUser || confirming)) {
        if (confirming) { closeNow(); return; }
        confirming = true; // se confirma en el siguiente fotograma
        return;
      }
      confirming = false;
      placed = p;
      setPos(placeAt(r));
    };
    if (anchor) raf = requestAnimationFrame(track);

    const items = () => Array.from(boxRef.current ? boxRef.current.querySelectorAll('[data-testid="menu-item"]:not([disabled])') : []);
    const focusFirst = requestAnimationFrame(() => { const f = items()[0]; f && f.focus({ preventScroll: true }); });

    const onKey = (e) => {
      if (closingRef.current) return;
      const list = items();
      const i = list.indexOf(document.activeElement);
      // stopPropagation: Esc/Tab cierran solo el menú, no una hoja que lo
      // contenga (que también escucha Escape en document, sin capture).
      if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); onClose(); }
      else if (e.key === 'ArrowDown') { e.preventDefault(); list[(i + 1) % list.length]?.focus(); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); list[(i - 1 + list.length) % list.length]?.focus(); }
      else if (e.key === 'Home') { e.preventDefault(); list[0]?.focus(); }
      else if (e.key === 'End') { e.preventDefault(); list[list.length - 1]?.focus(); }
    };
    const onScroll = (e) => {
      if (boxRef.current && boxRef.current.contains(e.target)) return;
      if (!anchor) { onClose(); return; }
      scrolled = true; // lo juzga `track` en este mismo fotograma
    };
    document.addEventListener('keydown', onKey, true);
    window.addEventListener('scroll', onScroll, true);
    return () => {
      cancelAnimationFrame(focusFirst);
      cancelAnimationFrame(raf);
      document.removeEventListener('keydown', onKey, true);
      window.removeEventListener('scroll', onScroll, true);
    };
  }, [open]);

  // El foco vuelve al ancla al cerrarse por cualquier camino (opción
  // elegida, toque fuera, Esc, Tab, scroll) — salvo que el elemento activo
  // ya no esté dentro del menú, señal de que otra capa (p. ej. una hoja que
  // `onSelect` acaba de abrir) ya se lo quedó.
  React.useEffect(() => {
    if (!open) return;
    const anchor = anchorRef && anchorRef.current;
    const before = document.activeElement;
    returnRef.current = () => menuReturnTarget(anchor, before);
    return () => {
      const a = document.activeElement, box = boxRef.current;
      if (a && a !== document.body && !(box && box.contains(a))) return;
      const target = returnRef.current ? returnRef.current() : null;
      if (target) target.focus({ preventScroll: true });
    };
  }, [open]);

  if (!open || !pos) return null;

  const choose = (it) => {
    if (it.disabled || closingRef.current) return;
    if (window.SLMotion) SLMotion.haptic.selection();
    closingRef.current = true;
    // La acción se aplica ya: la fila que abrió el menú debe reflejar el
    // cambio sin esperar a que el menú termine de cerrarse.
    it.onSelect && it.onSelect();
    onClose();
  };

  // Los eventos de React suben por el árbol de componentes, no por el DOM:
  // sin este corte, un clic del menú (portal) llegaría también a la fila
  // que lo contiene (p. ej. una `ListRow` con su propio onClick).
  const stop = (e) => e.stopPropagation();
  const layer = layerEl();

  return ReactDOM.createPortal(
    <div onClick={stop} onPointerDown={stop} style={{ position: 'absolute', inset: 0, zIndex: 60, pointerEvents: 'none' }}>
      <div aria-hidden="true" onClick={onClose} style={{ position: 'absolute', inset: 0, pointerEvents: 'auto', background: 'transparent' }} />
      <div ref={boxRef} role="menu" aria-label={label} data-testid={testid} style={{
        position: 'absolute', left: pos.left, top: pos.top, bottom: pos.bottom, width: pos.w, pointerEvents: 'auto',
        background: c.bgCard, border: `1px solid ${c.border}`, borderRadius: theme.radius.lg,
        boxShadow: c.shadow, overflow: 'hidden',
        animation: (window.SLMotion && SLMotion.reducedMotion()) ? 'none' : 'sl-fade .12s ease-out',
      }}>
        {sections.map((items, si) => (
          <React.Fragment key={si}>
            {si > 0 && <div aria-hidden="true" style={{ height: 1, background: c.border }} />}
            {items.map((it, ii) => {
              const role = it.checked === undefined ? 'menuitem' : (it.radio ? 'menuitemradio' : 'menuitemcheckbox');
              return (
                <button key={it.label} type="button" role={role} data-testid="menu-item" disabled={it.disabled}
                  aria-checked={it.checked === undefined ? undefined : (it.checked ? 'true' : 'false')}
                  onClick={() => choose(it)}
                  onMouseEnter={() => setHoverLabel(it.label)}
                  onMouseLeave={() => setHoverLabel(prev => prev === it.label ? null : prev)}
                  style={{
                    width: '100%', minHeight: 44, display: 'flex', alignItems: 'center', gap: 10,
                    padding: '0 16px 0 12px', border: 'none', textAlign: 'left',
                    cursor: it.disabled ? 'default' : 'pointer',
                    fontSize: 15, fontWeight: 600, fontFamily: theme.fontBody,
                    color: it.destructive ? c.danger : c.ink,
                    opacity: it.disabled ? 0.4 : 1,
                    background: hoverLabel === it.label ? c.bgAlt : 'transparent',
                    borderTop: ii > 0 ? `1px solid ${c.border}` : 'none',
                  }}>
                  <span style={{ width: 18, display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
                    {it.checked ? <Icon name="check" size={16} stroke={3} /> : null}
                  </span>
                  <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
                  {it.icon && <Icon name={it.icon} size={18} stroke={2.2} />}
                </button>
              );
            })}
          </React.Fragment>
        ))}
      </div>
    </div>, layer,
  );
}

// Selector de lo enfocable, usado para atrapar el foco (Tab) dentro del
// panel de `ActionSheet` mientras está abierto — es un diálogo modal, a
// diferencia de `Menu` (que solo mueve el foco con flechas entre sus items).
const trapFocus = (e, container) => {
  const list = Array.from(container.querySelectorAll(FOCUSABLE_SEL)).filter(el => el.offsetParent !== null);
  if (list.length === 0) return;
  const first = list[0], last = list[list.length - 1];
  if (e.shiftKey ? document.activeElement === first : document.activeElement === last) {
    e.preventDefault();
    (e.shiftKey ? last : first).focus({ preventScroll: true });
  }
};

// Hoja de confirmación (patrón «action sheet»): título/mensaje opcional +
// lista de acciones + un botón «cancelar» separado. Pensada sobre todo para
// confirmaciones destructivas (borrar producto/lista, vaciar el carrito),
// sustituyendo el patrón repetido de `Sheet` + dos `Btn` (ghost cancelar /
// solid destructivo) que había en cada pantalla — ver `screens-rest.jsx` y
// `smart-input.jsx`. `actions` es un array de `ActionSheetAction` (arriba);
// la acción con `destructive: true` lleva `data-testid="action-destructive"`
// para que los e2e puedan confirmarla sin depender de la copia.
function ActionSheet({ theme, open, title, message, actions, cancelLabel, onClose, testid = 'action-sheet' }) {
  const c = theme.c;
  const panelRef = React.useRef(null);
  const returnRef = React.useRef(null);

  // Aviso háptico al abrir cuando hay una acción destructiva — igual
  // criterio que `Menu.choose` (best-effort, `window.SLMotion` puede faltar
  // en entornos sin motion-core, p. ej. el arnés e2e).
  React.useEffect(() => {
    if (!open) return;
    if (window.SLMotion && actions.some(a => a.destructive)) SLMotion.haptic.warning();
  }, [open]);

  // Esc cierra (captura, para no dejar que una hoja contenedora que también
  // escuche Escape la cierre a la vez); Tab queda atrapado dentro del panel
  // — es un diálogo modal, no debe poder salirse hacia lo de detrás.
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
      else if (e.key === 'Tab' && panelRef.current) trapFocus(e, panelRef.current);
    };
    document.addEventListener('keydown', onKey, true);
    return () => document.removeEventListener('keydown', onKey, true);
  }, [open]);

  // Foco inicial en la primera acción (o en cancelar si no hay ninguna, p.
  // ej. cuando la pantalla que abre la hoja omite la acción destructiva
  // porque no está permitida) y devolución del foco a quien lo tenía al
  // abrirse, al cerrarse por cualquier camino.
  React.useEffect(() => {
    if (!open) return;
    returnRef.current = document.activeElement;
    const r = requestAnimationFrame(() => panelRef.current?.querySelector('button')?.focus({ preventScroll: true }));
    return () => {
      cancelAnimationFrame(r);
      const prev = returnRef.current;
      if (prev && prev !== document.body && prev.isConnected) prev.focus({ preventScroll: true });
    };
  }, [open]);

  if (!open) return null;

  const layer = document.getElementById('root') || document.body;
  const rowBase = {
    width: '100%', minHeight: 56, display: 'flex', alignItems: 'center', justifyContent: 'center',
    gap: 8, padding: '0 16px', border: 'none', background: 'transparent',
    fontSize: 16, fontWeight: 700, fontFamily: theme.fontDisplay, textAlign: 'center',
    cursor: 'pointer', WebkitTapHighlightColor: 'transparent',
  };

  return ReactDOM.createPortal(
    <div style={{ position: 'absolute', inset: 0, zIndex: 65 }}>
      <div aria-hidden="true" onClick={onClose} style={{
        position: 'absolute', inset: 0, background: 'rgba(14,14,12,0.5)',
        animation: 'sl-fade .18s ease-out',
      }} />
      <div ref={panelRef} role="dialog" aria-modal="true" aria-label={title || message} data-testid={testid} style={{
        position: 'absolute', left: 8, right: 8, bottom: 'calc(8px + env(safe-area-inset-bottom, 0px))',
        maxWidth: 480, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 8,
        animation: (window.SLMotion && SLMotion.reducedMotion()) ? 'none' : 'sl-slide-up .22s cubic-bezier(.2,.9,.3,1)',
      }}>
        <div style={{
          background: c.bgSheet, border: `1px solid ${c.border}`, borderRadius: theme.radius.xl,
          boxShadow: c.shadow, overflow: 'hidden',
        }}>
          {(title || message) && (
            <div style={{ padding: '16px 16px 14px', textAlign: 'center', borderBottom: `1px solid ${c.border}` }}>
              {title && <div style={{ fontSize: 14, fontWeight: 800, fontFamily: theme.fontDisplay, color: c.ink }}>{title}</div>}
              {message && <div style={{ fontSize: 13, fontFamily: theme.fontBody, color: c.inkSub, marginTop: title ? 4 : 0, lineHeight: 1.4 }}>{message}</div>}
            </div>
          )}
          {actions.map((a, i) => (
            <button key={a.label} type="button" data-testid={a.destructive ? 'action-destructive' : 'action-item'}
              onClick={() => { onClose(); a.onSelect && a.onSelect(); }}
              style={{
                ...rowBase, color: a.destructive ? c.danger : c.accent,
                borderTop: i > 0 ? `1px solid ${c.border}` : 'none',
              }}>
              {a.icon && <Icon name={a.icon} size={18} stroke={2.2} />}
              {a.label}
            </button>
          ))}
        </div>
        <button type="button" data-testid="action-cancel" onClick={onClose} style={{
          ...rowBase, background: c.bgSheet, border: `1px solid ${c.border}`,
          borderRadius: theme.radius.xl, boxShadow: c.shadow, color: c.ink,
        }}>
          {cancelLabel}
        </button>
      </div>
    </div>, layer,
  );
}

// Envoltorio de menú contextual (Task 4.4): mantener pulsado ~500ms sobre
// `children` (o clic derecho / Shift+F10 / tecla Menú en escritorio) abre un
// `Menu` anclado a este mismo envoltorio con `sections`. Portado del
// `ContextMenuTarget` de `feat/kraft-nativo`'s `src/ui.jsx`, pero sin su
// animación de "elevación" (`springTo`/`SLMotion.SPRINGS`/`theme.elev`, que
// no existen en esta rama — aquí solo se da la háptica `medium` al abrir) y
// sin el flag `dataset.menuOpen` acoplado a un `SwipeRow` separado (esta
// rama no tiene uno: la fila deslizable vive inline en `screen-list.jsx`).
// En su lugar expone un `ref` con `{ cancel, isOpen }` vía
// `useImperativeHandle` — el propio componente es un `React.forwardRef`, y
// quien lo usa le pasa una ref-callback (igual idioma que
// `rowRefs`/`revealRefs` ya usan en `screen-list.jsx`, en vez de un
// `useEffect` con dependencias que se dispararía en cada render). Dos usos:
// `cancel()` deja que los manejadores de swipe (`onTouchMove`) apaguen el
// temporizador de pulsación larga en cuanto el gesto se bloquea en
// horizontal o vertical ("gana el que se bloquea primero"), sin depender
// solo de la coincidencia entre el umbral de 8px del swipe y el de 10px de
// este componente; `isOpen` (getter sobre `openRef`, siempre al día) deja
// que esos mismos manejadores dejen de responder al dedo mientras el menú
// siga abierto — el equivalente de lo que kraft-nativo resuelve con
// `dataset.menuOpen` leído desde `SwipeRow`. `dataset.menuOpen` en el propio
// nodo sigue existiendo (lo pone `openMenu`/`closeMenu`) por si algún día se
// necesita desde fuera vía `closest('[data-menu-open]')`, pero hoy nada lo
// lee — la coordinación real con el swipe es `cancel()`/`isOpen` explícitos.
const ContextMenuTarget = React.forwardRef(function ContextMenuTarget({ theme, sections, disabled, label, children }, ref) {
  const wrapRef = React.useRef(null);
  const [open, setOpen] = React.useState(false);
  const timers = React.useRef([]);
  const start = React.useRef(null);
  const suppress = React.useRef(false);
  const openRef = React.useRef(false);

  const clear = React.useCallback(() => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
    start.current = null;
  }, []);

  React.useEffect(() => () => clear(), [clear]);
  // `isOpen` como getter sobre `openRef` (no sobre el estado `open`): así el
  // valor que lee quien nos llama está siempre al día sin que este handle
  // tenga que recrearse en cada apertura/cierre — coordinación con el swipe
  // de la fila (`onTouchStart`/`onTouchMove` en `screen-list.jsx`), que debe
  // dejar de responder al dedo mientras el menú siga abierto.
  React.useImperativeHandle(ref, () => ({
    cancel: clear,
    get isOpen() { return openRef.current; },
  }), [clear]);

  const openMenu = () => {
    clear();
    // Android dispara `contextmenu` además de la pulsación larga: una sola
    // apertura y una sola háptica, igual que en `feat/kraft-nativo`.
    if (openRef.current) return;
    openRef.current = true;
    suppress.current = true;
    if (wrapRef.current) wrapRef.current.dataset.menuOpen = 'true';
    if (window.SLMotion) SLMotion.haptic.medium();
    setOpen(true);
  };
  const closeMenu = () => {
    openRef.current = false;
    if (wrapRef.current) delete wrapRef.current.dataset.menuOpen;
    setOpen(false);
    setTimeout(() => { suppress.current = false; }, 50);
  };

  // El envoltorio es un `<div>` sin rol propio (regla X2: nada interactivo
  // anidado dentro de otro control — no debe competir con el botón de
  // categoría, el de marcar comprado en escritorio, ni con los paneles de
  // deslizar). Con teclado se abre desde el foco de cualquier control de
  // dentro de la fila (Shift+F10 / tecla Menú, burbujea hasta aquí).
  return (
    <div ref={wrapRef}
      style={{ WebkitTouchCallout: 'none', WebkitUserSelect: 'none', userSelect: 'none' }}
      onPointerDown={(e) => {
        if (disabled || e.pointerType === 'mouse') return;
        start.current = { x: e.clientX, y: e.clientY };
        timers.current = [setTimeout(openMenu, 500)];
      }}
      onPointerMove={(e) => {
        const s = start.current;
        if (s && Math.hypot(e.clientX - s.x, e.clientY - s.y) > 10) clear();
      }}
      onPointerUp={clear}
      onPointerCancel={clear}
      onContextMenu={(e) => { if (disabled) return; e.preventDefault(); openMenu(); }}
      onKeyDown={(e) => {
        if (!disabled && ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu')) {
          e.preventDefault();
          openMenu();
        }
      }}
      onClickCapture={(e) => {
        // Suprime el clic que el navegador dispara justo después de soltar
        // una pulsación larga que abrió el menú (p. ej. el botón de
        // categoría no debe además abrir su propio selector).
        if (suppress.current && wrapRef.current && wrapRef.current.contains(e.target)) {
          e.stopPropagation();
          e.preventDefault();
          suppress.current = false;
        }
      }}>
      {children}
      <Menu theme={theme} open={open} anchorRef={wrapRef} sections={sections} onClose={closeMenu} label={label} />
    </div>
  );
});

// Tutorial de bienvenida (Task 8.4): tres pasos para los gestos que no se
// descubren solos — mantener pulsado (menú contextual, Task 4.4), deslizar
// la fila (editar/eliminar a la izquierda, marcar comprado a la derecha,
// Task 4.4/7.1) y la cabecera de la lista (menú del título + «…», Task 6.2).
// Se muestra en el primer arranque (`src/app.jsx`) y desde Ajustes → «Ver
// introducción» (`src/screens-rest.jsx`).
//
// Portado de `OnboardingOverlay` en `feat/kraft-nativo`'s `src/ui.jsx`
// (`39a32ef`), reescrito con los tokens de esta rama (Kraft clásico):
// - El círculo del icono reutiliza la misma convención que
//   `PullToRefreshIndicator` (src/core.jsx, Task 8.2) para un indicador
//   circular flotante: `c.bgCard` + borde `c.border` + `c.shadow` — no
//   `c.fillSoft`/`c.tint`, que no existen en esta rama.
// - El scrim usa el mismo literal `rgba(14,14,12,0.5)` que `Sheet`
//   (`mode="dialog"`, src/core.jsx) y `ActionSheet` usan para el suyo — no
//   `c.scrim`.
// - El título iguala el bloque de título de `Sheet` (`rem(22)`, peso 800,
//   `theme.fontDisplay`) — no `theme.type.title2`, que no existe aquí.
// - El cuerpo iguala el `message` de `ActionSheet` (13px, `c.inkSub`,
//   `theme.fontBody`, `lineHeight: 1.4`) — no `theme.type.subhead`.
// - La tarjeta usa `c.shadow` (el equivalente real de `theme.elev.menu`) y
//   `theme.radius.xl` (el radio que de verdad usa `Sheet` para sus tarjetas,
//   no un `theme.radius.sheet` que no existe).
// - Sin `usePresence` (no existe en esta rama, confirmado por grep): mismo
//   patrón ya establecido por `ActionSheet`/`Sheet` — `if (!open) return
//   null` desmonta al instante (sin animación de salida) y una animación de
//   entrada por CSS (guardada tras `SLMotion.reducedMotion()`, igual que el
//   panel de `ActionSheet`) corre de nuevo cada vez que el componente vuelve
//   a montarse.
// - El botón principal es el `Btn` de `src/core.jsx` (`variant="solid"`),
//   no un `PrimaryButton` que no existe aquí.
// - El cambio de paso reutiliza `springTo`/`setNow` (Task 8.2, `core.jsx`) —
//   esto SÍ existe ya en esta rama y es la reutilización que se pide.
// - `trapFocus` es el mismo de más arriba en este fichero (Task 4.2), no se
//   redefine. `SR_TEXT` es el de `src/core.jsx` (Task 8.2): un `const`
//   de nivel superior en un script clásico sin módulos, visible aquí porque
//   `core.jsx` se carga antes que `ui.jsx` y ambos comparten el mismo
//   entorno léxico global del documento (mismo mecanismo ya confirmado en
//   la revisión de la Task 8.2).
// - El foco vuelve a quien lo tenía antes de abrir el tutorial, al cerrarse
//   por cualquier camino (Saltar/Entendido/Esc) — misma convención que
//   `ActionSheet`/`Sheet`/`Menu` en esta rama, no un hueco heredado del
//   propio `OnboardingOverlay` de referencia (que no lo hacía). En el
//   arranque automático no hay un "opener" real que recuperar: el guard
//   `prev !== document.body` ya cubre ese caso sin necesitar una rama aparte.
// - `isDesktop` (recibido como prop, igual que ya hace cada pantalla vía
//   `app.isDesktop` en vez de volver a llamar a `useViewport()`) decide qué
//   copy se usa para los pasos 1 y 2: en escritorio no hay pulsación larga
//   (`ContextMenuTarget` corta en seco con `pointerType === 'mouse'`) ni
//   deslizar fila (`onTouchStart`/`onTouchMove` de `screen-list.jsx` cortan
//   con `if (isDesktop) return`, y los paneles de deslizar ni se montan:
//   `{!isDesktop && (...)}`) — así que esos dos pasos usan
//   `t.onboarding.stepsDesktop` (clic derecho / hover) en vez de
//   `t.onboarding.steps` (pulsar y mantener / deslizar). El paso 3 (tocar el
//   nombre / «…») funciona igual en ambas plataformas, así que
//   `stepsDesktop[2]` repite el mismo texto que `steps[2]`.
function OnboardingOverlay({ theme, t, open, onClose, isDesktop }) {
  const c = theme.c;
  const [step, setStep] = React.useState(0);
  const panelRef = React.useRef(null);
  const contentRef = React.useRef(null);
  const returnRef = React.useRef(null);
  const steps = (isDesktop && t.onboarding && t.onboarding.stepsDesktop) || (t.onboarding && t.onboarding.steps) || [];
  const last = step >= steps.length - 1;

  // Se reabre siempre en el paso 1, tanto la primera vez como cada vez que
  // se reabre desde Ajustes → «Ver introducción».
  React.useEffect(() => { if (open) setStep(0); }, [open]);

  // Cambio de paso: fundido + un palmo de deslizamiento lateral con el
  // muelle `standard` de `SLMotion` (Task 8.2). Con movimiento reducido,
  // cambio seco (sin animar).
  const applyStep = (el, x) => {
    el.style.transform = x ? `translate3d(${x}px, 0, 0)` : '';
    el.style.opacity = String(Math.max(0, 1 - Math.abs(x) / 26));
  };
  React.useLayoutEffect(() => {
    const el = contentRef.current;
    if (!el || !open) return;
    if (window.SLMotion && SLMotion.reducedMotion()) { setNow(el, 'x', 0, applyStep); return; }
    setNow(el, 'x', 20, applyStep);
    springTo(el, 'x', 0, 'standard', applyStep);
  }, [step, open]);

  // Modal: Esc cierra (captura + `stopPropagation`, mismo idioma que
  // `Menu`/`ActionSheet` más arriba en este fichero, para no dejar que una
  // hoja contenedora que también escuche Escape se cierre a la vez); Tab
  // queda atrapado dentro del panel con el `trapFocus` ya definido para
  // `ActionSheet`. El foco inicial va al botón principal, y al cerrarse por
  // cualquier camino (Saltar, Entendido, Esc) el foco vuelve a quien lo
  // tenía antes de abrir — misma convención que `ActionSheet`/`Sheet`/`Menu`
  // en esta rama (capturar `document.activeElement` al abrir, devolverlo en
  // la limpieza del efecto).
  React.useEffect(() => {
    if (!open) return;
    returnRef.current = document.activeElement;
    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
      else if (e.key === 'Tab' && panelRef.current) { e.stopPropagation(); trapFocus(e, panelRef.current); }
    };
    document.addEventListener('keydown', onKey, true);
    const r = requestAnimationFrame(() => panelRef.current?.querySelector('[data-testid="onboarding-next"]')?.focus({ preventScroll: true }));
    return () => {
      document.removeEventListener('keydown', onKey, true);
      cancelAnimationFrame(r);
      const prev = returnRef.current;
      if (prev && prev !== document.body && prev.isConnected) prev.focus({ preventScroll: true });
    };
  }, [open]);

  if (!open) return null;

  const layer = document.getElementById('sl-sheet-layer') || document.getElementById('root') || document.body;
  const actual = steps[step] || { title: '', body: '' };
  // Glifos ya existentes en la app (ninguno inventado): 'dots' es el mismo
  // glifo que ya pinta el botón «…» de la cabecera (Task 6.2) — aquí es solo
  // un icono genérico de "menú" para el paso 1, no representa ese botón
  // concreto (el paso 1 es el menú contextual de la fila, Task 4.4). El par
  // de galones representa el deslizamiento (paso 2) y 'stack' (cambiar de
  // lista) representa la cabecera (paso 3).
  const glyph = step === 1
    ? <span style={{ display: 'flex', alignItems: 'center' }}><Icon name="chevron-left" size={30} stroke={2.6} /><Icon name="chevron-right" size={30} stroke={2.6} /></span>
    : <Icon name={step === 0 ? 'dots' : 'stack'} size={38} stroke={2.2} />;

  return ReactDOM.createPortal(<>
    <div aria-hidden="true" onClick={onClose} style={{
      position: 'absolute', inset: 0, zIndex: 70, background: 'rgba(14,14,12,0.5)',
      animation: 'sl-fade .18s ease-out',
    }} />
    <div style={{
      position: 'absolute', inset: 0, zIndex: 71,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, pointerEvents: 'none',
    }}>
      <div ref={panelRef} role="dialog" aria-modal="true" aria-label={t.onboarding.title} data-testid="onboarding" tabIndex={-1}
        style={{
          width: 'min(360px, 100%)', background: c.bgSheet, color: c.ink, borderRadius: theme.radius.xl,
          border: `1px solid ${c.border}`, boxShadow: c.shadow, padding: '8px 20px 20px', pointerEvents: 'auto', outline: 'none',
          animation: (window.SLMotion && SLMotion.reducedMotion()) ? 'none' : 'sl-bounce-in .22s cubic-bezier(.2,1.3,.4,1)',
        }}>
        <div style={{ display: 'flex', justifyContent: 'flex-end', minHeight: 44, alignItems: 'center' }}>
          <button type="button" data-testid="onboarding-skip" onClick={onClose} style={{
            background: 'transparent', border: 'none', color: c.inkSub, cursor: 'pointer',
            fontSize: 14, fontWeight: 700, fontFamily: theme.fontDisplay,
            minHeight: 44, minWidth: 44, padding: '0 4px', margin: '0 -4px',
            display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
          }}>{t.onboarding.skip}</button>
        </div>
        <div ref={contentRef} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 8 }}>
          <span aria-hidden="true" style={{
            width: 76, height: 76, borderRadius: '50%',
            background: c.bgCard, border: `1px solid ${c.border}`, boxShadow: c.shadow, color: c.accent,
            display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginBottom: 6,
          }}>{glyph}</span>
          <div style={{ fontSize: rem(22), fontWeight: 800, fontFamily: theme.fontDisplay, color: c.ink }}>{actual.title}</div>
          <div style={{ fontSize: 13, fontFamily: theme.fontBody, color: c.inkSub, lineHeight: 1.4 }}>{actual.body}</div>
        </div>
        <div aria-hidden="true" data-testid="onboarding-dots" style={{ display: 'flex', justifyContent: 'center', gap: 6, margin: '18px 0 14px' }}>
          {steps.map((_, i) => (
            <span key={i} data-testid="onboarding-dot" style={{
              width: i === step ? 18 : 6, height: 6, borderRadius: theme.radius.pill,
              background: i === step ? c.accent : c.border, transition: 'width .18s ease-out',
            }} />
          ))}
        </div>
        <span role="status" aria-live="polite" style={SR_TEXT}>{tpl(t.onboarding.step, { n: step + 1, total: steps.length })}</span>
        <Btn theme={theme} variant="solid" full testid="onboarding-next"
          onClick={() => { if (window.SLMotion) SLMotion.haptic.light(); if (last) onClose(); else setStep(s => s + 1); }}>
          {last ? t.onboarding.done : t.onboarding.next}
        </Btn>
      </div>
    </div>
  </>, layer);
}

Object.assign(window, { Menu, ActionSheet, ContextMenuTarget, Stepper, OnboardingOverlay });
