/* ops-workspace.jsx — адаптивное рабочее поле.
   Сетка 10 колонок × строки фиксированной высоты: карточка = ширина (S/M/L) × высота (1..3).
   Две S высотой 1 встают ровно в одну M высотой 2. Управление карточкой — в popover «⋯»,
   «развернуть» вынесено в правый верхний угол. Виджет описан декларативно
   (metrics / chart / list / table) — карточка сама выбирает представление по своему размеру
   либо по явной настройке «Содержимое». Персист — localStorage (в проде: Supabase layouts.data jsonb).

   Модуль переиспользуемый: window.OpsWorkspace принимает { widgets, presets, storeKey }. */
(function () {
  const R = React;
  const RD = ReactDOM;
  const { Ic } = window;

  const SCALES = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5];
  const GRID_COLS = 10;
  const DEFAULT_H = { S: 1, M: 2, L: 2 };
  /* ширины подобраны под пороги тиров (300 / 670 px по телу карточки):
     S → метрики, M → список, L → таблица с графиком. */
  const DEFAULT_W = { S: 1, M: 3, L: 6 };

  /* Число колонок — параметр: операционный центр во всю ширину держит 10,
     кабинету с сайдбаром столько не влезает (колонка выходит ~94px, туда
     не помещается ни заголовок, ни число). */
  const geometry = (w, cols = GRID_COLS) => {
    const size = w.size || 'S';
    const wide = Math.min(DEFAULT_W[size] || 1, cols);
    return {
      w: w.shape === 'vertical' ? Math.max(1, Math.ceil(wide / 2)) : wide,
      h: w.h || DEFAULT_H[size] || 1,
    };
  };
  const overlaps = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
  const firstOpen = (placed, shape, fromY = 0, cols = GRID_COLS) => {
    for (let y = Math.max(0, fromY); y < 200; y++) {
      for (let x = 0; x <= cols - shape.w; x++) {
        const candidate = { x, y, ...shape };
        if (!placed.some((p) => overlaps(candidate, p))) return candidate;
      }
    }
    return { x: 0, y: 200, ...shape };
  };
  /* priorityId получает свою клетку первым: карточка, которую только что бросили,
     занимает целевое место, а прежний жилец выдавливается вниз. */
  const layoutWidgets = (items, priorityId, cols = GRID_COLS) => {
    const positioned = [];
    const next = items.map((item) => ({ ...item }));
    const order = next.map((w, i) => ({ w, i })).sort((a, b) => {
      if (priorityId) {
        if (a.w.id === priorityId) return -1;
        if (b.w.id === priorityId) return 1;
      }
      return ((a.w.y || 0) - (b.w.y || 0)) || ((a.w.x || 0) - (b.w.x || 0)) || a.i - b.i;
    });
    order.forEach(({ w }) => {
      const shape = geometry(w, cols);
      const requested = Number.isInteger(w.x) && Number.isInteger(w.y) && w.x >= 0 && w.x + shape.w <= cols
        ? { x: w.x, y: Math.max(0, w.y), ...shape }
        : null;
      let pos = requested || firstOpen(positioned, shape, 0, cols);
      while (positioned.some((p) => overlaps(pos, p))) pos = firstOpen(positioned, shape, pos.y + 1, cols);
      w.x = pos.x; w.y = pos.y;
      positioned.push(pos);
    });
    return next;
  };
  const tileStyle = (w, cols) => {
    const g = geometry(w, cols);
    return { gridColumn: `${(w.x || 0) + 1} / span ${g.w}`, gridRow: `${(w.y || 0) + 1} / span ${g.h}` };
  };

  const fmt = (n) => (n || 0).toLocaleString('ru-RU');
  const compact = (n) => (Math.abs(n) >= 1e6 ? (n / 1e6).toFixed(1).replace('.0', '') + ' млн' : Math.abs(n) >= 1e3 ? Math.round(n / 1e3) + 'к' : String(n));

  /* ═══════════ ЦВЕТ: заливка + группировка палитры (Нейтральные / Акценты) ═══════════ */
  const CANVAS = '#faf8f5';
  const PALETTE = [
    { id: 'default',  label: 'Без цвета', bg: null },
    { id: 'sand',     label: 'Песок',     bg: '#e8dcc8', a: 0.55, ink: '#b89a6a' },
    { id: 'mint',     label: 'Мята',      bg: '#c8e6d4', a: 0.55, ink: '#5a9e78' },
    { id: 'green',    label: 'Зелёный',   bg: '#a8dcb8', a: 0.6,  ink: '#3f8f5a' },
    { id: 'amber',    label: 'Жёлтый',    bg: '#f2dc8a', a: 0.6,  ink: '#c2a23e' },
    { id: 'sky',      label: 'Небо',      bg: '#c6dcf0', a: 0.55, ink: '#5a7fa8' },
    { id: 'linen',    label: 'Лён',       bg: '#e4ddd2', a: 0.58, ink: '#8f816c' },
    { id: 'sage',     label: 'Полынь',    bg: '#d0dccf', a: 0.58, ink: '#6e8a72' },
    { id: 'steel',    label: 'Сталь',     bg: '#d2d8e0', a: 0.58, ink: '#6a7d92' },
    /* акценты темы: коралл/лайм/голубой из «Стильных вариантов» — они ярче
       прежней пастели и работают как выделение одной-двух карточек */
    { id: 'coral',    label: 'Коралл',    bg: '#ffe0d9', a: 0.9,  ink: '#f26445' },
    { id: 'lime',     label: 'Лайм',      bg: '#f0f7ad', a: 0.9,  ink: '#9aa832' },
    { id: 'blue',     label: 'Голубой',   bg: '#dcecff', a: 0.9,  ink: '#5b86c4' },
    { id: 'graphite', label: 'Графит',    bg: '#292d37', a: 0.94, ink: '#292d37' },
  ];
  /* Grouped Palette: нейтрали vs акценты, внутри по светлоте (как в mockup) */
  const PALETTE_GROUPS = [
    { id: 'neutrals', label: 'Нейтральные', ids: ['default', 'steel', 'sage', 'graphite'] },
    { id: 'accents',  label: 'Акценты',     ids: ['coral', 'lime', 'blue', 'sand', 'mint', 'green', 'amber', 'sky', 'linen'] },
  ];
  const PALETTE_LEGACY = { rose: 'linen', lilac: 'steel', clay: 'sage', Стекло: 'default' };
  const byId = (id) => {
    const mapped = PALETTE_LEGACY[id] || id;
    return PALETTE.find((p) => p.id === mapped) || PALETTE[0];
  };

  const hex2rgb = (h) => { const c = h.replace('#', ''); return [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16)); };
  const rgb2hex = (r) => '#' + r.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
  const blend = (fgHex, alpha, bgHex) => {
    const f = hex2rgb(fgHex), b = hex2rgb(bgHex);
    return rgb2hex(f.map((v, i) => v * alpha + b[i] * (1 - alpha)));
  };
  function luminance(hex) {
    const rgb = hex2rgb(hex).map((v) => v / 255)
      .map((v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
    return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
  }
  const contrast = (l1, l2) => (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);

  function cardVars(colorId, mode) {
    const p = byId(colorId);
    if (!p || !p.bg) return null;
    const outlineOnly = mode === 'outline';
    const ink = p.ink || p.bg;
    const [ir, ig, ib] = hex2rgb(ink);

    if (outlineOnly) {
      const dark = luminance(ink) < 0.28;
      /* Только контур + ink. Без --ws-card-bg/--card заливки —
         фон как у «Без цвета»; inherit с родителя режем в CSS. */
      return {
        '--ws-outline': `rgb(${ir},${ig},${ib})`,
        '--ws-line': `rgba(${ir},${ig},${ib},${dark ? 0.78 : 0.62})`,
        '--ws-fg': '#1a1714',
        '--ws-fg-sub': 'rgba(26,23,20,0.66)',
        '--ws-track': `rgba(${ir},${ig},${ib},0.12)`,
        '--ws-accent-hi': `rgb(${ir},${ig},${ib})`,
        '--foreground': '#1a1714',
        '--text-secondary': 'rgba(26,23,20,0.66)',
        '--text-tertiary': 'rgba(26,23,20,0.55)',
        '--border-subtle': `rgba(${ir},${ig},${ib},${dark ? 0.78 : 0.62})`,
        '--secondary': `rgba(${ir},${ig},${ib},0.12)`,
      };
    }

    const alpha = p.a != null ? p.a : 0.6;
    const eff = blend(p.bg, alpha, CANVAS);
    const L = luminance(eff);
    const light = contrast(L, 1) > contrast(L, luminance('#1a1714'));
    const fg = light ? '#ffffff' : '#1a1714';
    const rgba = (a) => (light ? `rgba(255,255,255,${a})` : `rgba(26,23,20,${a})`);
    const [r, g, b] = hex2rgb(p.bg);
    return {
      '--ws-card-bg': `rgba(${r},${g},${b},${alpha})`,
      '--ws-fg': fg,
      '--ws-fg-sub': rgba(light ? 0.76 : 0.66),
      '--ws-line': rgba(0.16),
      '--ws-outline': `rgb(${ir},${ig},${ib})`,
      '--ws-track': rgba(0.14),
      '--ws-accent-hi': `rgb(${ir},${ig},${ib})`,
    };
  }

  function swatchStyle(p) {
    if (!p || !p.bg) {
      return { background: 'var(--card)', border: '1.5px dashed rgba(26,23,20,.28)' };
    }
    const a = p.a != null ? p.a : 0.6;
    return {
      background: `rgba(${hex2rgb(p.bg).join(',')},${a})`,
      border: '1px solid rgba(0,0,0,.12)',
    };
  }

  /* Grouped Palette picker — две строки с подписями под кольцами */
  function GroupedPalette({ value, onChange, className }) {
    const cur = value || 'default';
    return (
      <div className={'ws-pal' + (className ? ' ' + className : '')} role="group" aria-label="Цвет">
        {PALETTE_GROUPS.map((g) => (
          <div key={g.id} className="ws-pal-group">
            <div className="ws-pal-group-lbl">{g.label}</div>
            <div className={'ws-pal-row ws-pal-row--n' + g.ids.length}>
              {g.ids.map((id) => {
                const p = byId(id);
                const on = cur === p.id;
                const ring = g.id === 'accents' && p.bg;
                const dotStyle = !p.bg
                  ? undefined
                  : ring
                    ? { background: 'transparent', borderColor: p.ink || p.bg }
                    : { background: `rgba(${hex2rgb(p.bg).join(',')},${p.a != null ? p.a : 0.6})`, borderColor: 'rgba(0,0,0,.1)' };
                return (
                  <button
                    key={p.id}
                    type="button"
                    className={'ws-pal-sw' + (on ? ' is-on' : '') + (!p.bg ? ' is-none' : '') + (ring ? ' is-ring' : '')}
                    aria-pressed={on}
                    aria-label={'Цвет: ' + p.label}
                    title={p.label}
                    onClick={() => onChange(p.id === 'default' ? null : p.id)}
                  >
                    <span className="ws-pal-dot" style={dotStyle} aria-hidden="true" />
                    <span className="ws-pal-name">{p.label}</span>
                  </button>
                );
              })}
            </div>
          </div>
        ))}
      </div>
    );
  }

  /* ═══════════ ГРАФИКИ (SVG, наследуют цвет карточки через currentColor) ═══════════ */

  /* Первый замер — синхронно в layout-эффекте, а не по первому колбэку
     ResizeObserver: в скрытой вкладке RO не вызывается вовсе, и график
     остался бы с чужой шириной. RO нужен только для последующих изменений. */
  function useWidth(ref, initial) {
    const [w, setW] = R.useState(initial);
    R.useLayoutEffect(() => {
      const el = ref.current; if (!el) return undefined;
      const read = () => setW(Math.max(80, Math.round(el.clientWidth || initial)));
      read();
      if (!window.ResizeObserver) return undefined;
      const ro = new ResizeObserver(read); ro.observe(el);
      return () => ro.disconnect();
    }, []);
    return w;
  }

  /* Как useWidth, но меряет обе стороны — для графиков, заполняющих фрейм
     (адаптивность: SVG рисуется под реальный бокс карточки, а не фикс-px). */
  function useSize(ref, initW, initH) {
    const [s, setS] = R.useState({ w: initW, h: initH });
    R.useLayoutEffect(() => {
      const el = ref.current; if (!el) return undefined;
      const read = () => setS({ w: Math.max(80, Math.round(el.clientWidth || initW)), h: Math.max(24, Math.round(el.clientHeight || initH)) });
      read();
      if (!window.ResizeObserver) return undefined;
      const ro = new ResizeObserver(read); ro.observe(el);
      return () => ro.disconnect();
    }, []);
    return s;
  }

  /* Бокс, отдающий детям измеренные w×h и растущий внутри flex-колонки карточки.
     Донат/рейтинг сами считают размер от бокса — заполняют остаток по высоте. */
  function FillBox({ minH, className, children }) {
    const ref = R.useRef(null);
    const { w, h } = useSize(ref, 140, minH || 48);
    return <div className={'ws-fill' + (className ? ' ' + className : '')} ref={ref} style={{ minHeight: minH || 48 }}>{children(w, h)}</div>;
  }

  /* Кадр задаёт SVG пиксельный размер: viewBox совпадает с реальным боксом,
     поэтому масштаб по осям одинаковый. С preserveAspectRatio="none" подписи
     осей и скругления столбцов растягивались по горизонтали в разы.
     h='fill' — кадр тянется по высоте (flex:1) и отдаёт телу измеренную высоту. */
  function ChartFrame({ h, minH, children }) {
    const ref = R.useRef(null);
    const fill = h === 'fill' || h == null;
    const { w, h: mh } = useSize(ref, 300, typeof h === 'number' ? h : (minH || 48));
    const style = fill ? { minHeight: minH || 44 } : { height: h };
    return <div className={'ws-chart-frame' + (fill ? ' is-fill' : '')} ref={ref} style={style}>{children(w, fill ? mh : h)}</div>;
  }

  /* линейный: сглаженная область + линия + точка последнего значения.
     prev — прошлый период: рисуется бледной пунктирной линией (режим сравнения). */
  function LineChartBody({ data, labels, h, w, showAxis, prev }) {
    const pad = showAxis ? 16 : 4;
    const all = prev && prev.length ? data.concat(prev) : data;
    const min = Math.min(...all), max = Math.max(...all), span = max - min || 1;
    const x = (i, n) => pad + (i / ((n || data.length) - 1)) * (w - pad * 2);
    const y = (v) => h - pad - ((v - min) / span) * (h - pad * 2);
    const smooth = (arr) => arr.map((v, i) => [x(i, arr.length), y(v)]).reduce((acc, p, i, a) => {
      if (i === 0) return `M${p[0]},${p[1]}`;
      const p0 = a[i - 2] || a[i - 1], p1 = a[i - 1], p2 = p, p3 = a[i + 1] || p;
      const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
      const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
      return `${acc} C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${p2[0]},${p2[1]}`;
    }, '');
    const pts = data.map((v, i) => [x(i), y(v)]);
    const d = smooth(data);
    const prevD = prev && prev.length ? smooth(prev) : null;
    const area = `${d} L${x(data.length - 1)},${h - pad} L${x(0)},${h - pad} Z`;
    const gid = R.useMemo(() => 'wsg' + Math.random().toString(36).slice(2, 7), []);
    const last = pts[pts.length - 1];
    return (
      <svg className="ws-chart" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика">
        <defs>
          <linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor="currentColor" stopOpacity=".18" />
            <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
          </linearGradient>
        </defs>
        {showAxis && [0, 0.5, 1].map((t) => <line key={t} className="grid-line" x1={pad} x2={w - pad} y1={pad + t * (h - pad * 2)} y2={pad + t * (h - pad * 2)} />)}
        {showAxis && (() => { /* пунктир от пика к оси — акцент на актуальном значении */
          const pk = data.reduce((mi, v, i) => (v > data[mi] ? i : mi), 0);
          return <line className="ws-mark-guide" x1={x(pk)} x2={x(pk)} y1={y(data[pk])} y2={h - pad} />;
        })()}
        <path d={area} fill={`url(#${gid})`} />
        {prevD && <path className="ws-prev-line" d={prevD} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />}
        <path d={d} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
        {!showAxis && <circle cx={last[0]} cy={last[1]} r="3.2" fill="currentColor" />}
        {showAxis && (() => { /* акцентная последняя точка + callout со значением */
          const label = String(data[data.length - 1]);
          const bw = Math.max(26, label.length * 6.5 + 12), bh = 16;
          let bx = Math.max(2, Math.min(last[0] - bw / 2, w - bw - 2));
          let by = last[1] - bh - 9; if (by < 2) by = last[1] + 9;
          return (
            <g>
              <rect className="ws-callout-bg" x={bx} y={by} width={bw} height={bh} rx="8" />
              <text className="ws-callout-t" x={bx + bw / 2} y={by + bh / 2 + 3.4} textAnchor="middle">{label}</text>
              <circle cx={last[0]} cy={last[1]} r="4" fill="var(--ws-accent-hi, #c8871a)" stroke="var(--ws-card-bg, #fff)" strokeWidth="1.5" />
            </g>
          );
        })()}
        {showAxis && labels && labels.map((l, i) => <text key={i} className="axis-t" x={x(i)} y={h - 3} textAnchor="middle">{l}</text>)}
      </svg>
    );
  }

  const LineChart = ({ h = 96, minH, ...rest }) => (
    <ChartFrame h={h} minH={minH}>{(w, bh) => <LineChartBody h={bh} w={w} {...rest} />}</ChartFrame>
  );

  /* столбчатый: закруглённые столбцы, последний — акцентный */
  function BarChartBody({ data, labels, h, w, showAxis }) {
    const pad = showAxis ? 16 : 4;
    const max = Math.max(...data) || 1;
    const bw = (w - pad * 2) / data.length;
    const gap = Math.min(showAxis ? 8 : 4, bw * 0.22);
    return (
      <svg className="ws-chart" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Сравнение">
        {showAxis && <line className="grid-line" x1={pad} x2={w - pad} y1={h - pad} y2={h - pad} />}
        {data.map((v, i) => {
          const bh = Math.max(2, (v / max) * (h - pad * 2));
          /* в полном графике столбец не шире 44px; в спарклайне — на всю долю слота */
          const raw = Math.max(2, bw - gap);
          const bWidth = showAxis ? Math.min(raw, 44) : raw;
          const cx = pad + i * bw + bw / 2;
          const hot = i === data.length - 1; /* акцентный столбец — актуальное значение */
          return (
            <rect key={i} x={cx - bWidth / 2} y={h - pad - bh} width={bWidth} height={bh}
              rx={Math.min(5, bWidth / 2)} fill={hot && showAxis ? 'var(--ws-accent-hi, #c8871a)' : 'currentColor'}
              opacity={hot ? 1 : (showAxis ? 0.32 : 0.48)} />
          );
        })}
        {showAxis && (() => { /* подпись значения над акцентным столбцом */
          const i = data.length - 1, v = data[i];
          const bh = Math.max(2, (v / max) * (h - pad * 2));
          const cx = pad + i * bw + bw / 2;
          return <text className="ws-callout-lbl" x={cx} y={Math.max(9, h - pad - bh - 5)} textAnchor="middle">{v}</text>;
        })()}
        {showAxis && labels && labels.map((l, i) => <text key={i} className="axis-t" x={pad + i * bw + bw / 2} y={h - 3} textAnchor="middle">{l}</text>)}
      </svg>
    );
  }

  const BarChart = ({ h = 96, minH, ...rest }) => (
    <ChartFrame h={h} minH={minH}>{(w, bh) => <BarChartBody h={bh} w={w} {...rest} />}</ChartFrame>
  );

  /* круговой (donut): доли одним тоном с убывающей прозрачностью + центральное значение */
  function DonutChart({ segments, center, sub, size = 108 }) {
    const segs = (segments || []).filter((s) => (Number(s.v) || 0) > 0);
    const total = segs.reduce((a, s) => a + s.v, 0) || 1;
    /* на мини-spark толстый stroke + round caps превращают доли в «точки» */
    const stroke = size < 72 ? Math.max(6, Math.round(size * 0.14)) : 11;
    const strokeHi = stroke + (size < 72 ? 1 : 2);
    const pad = size / 2 - strokeHi / 2 - 1;
    const r = Math.max(10, pad);
    const cx = size / 2, cy = size / 2, C = 2 * Math.PI * r;
    const many = segs.length > 4 || size < 72;
    const gap = many ? 1.5 : 3;
    const cap = many ? 'butt' : 'round';
    let acc = 0;
    return (
      <svg className="ws-chart ws-chart--fixed" viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Структура">
        <circle cx={cx} cy={cy} r={r} fill="none" stroke="currentColor" strokeOpacity=".1" strokeWidth={stroke} />
        {segs.map((s, i) => {
          const len = (s.v / total) * C;
          const dash = Math.max(0.5, len - gap);
          const el = (
            <circle key={i} cx={cx} cy={cy} r={r} fill="none"
              stroke={i === 0 ? 'var(--ws-accent-hi, #c8871a)' : 'currentColor'} strokeOpacity={i === 0 ? 1 : Math.max(0.22, 1 - i * 0.18)}
              strokeWidth={i === 0 ? strokeHi : stroke} strokeLinecap={cap}
              strokeDasharray={`${dash} ${C - dash}`}
              strokeDashoffset={-acc} transform={`rotate(-90 ${cx} ${cy})`} />
          );
          acc += len;
          return el;
        })}
        {center != null && center !== '' && (
          <text className="ws-donut-c" x={cx} y={cy - (sub ? 1 : 0)} textAnchor="middle" style={size < 64 ? { fontSize: 11 } : undefined}>{center}</text>
        )}
        {sub && <text className="ws-donut-s" x={cx} y={cy + 14} textAnchor="middle">{sub}</text>}
      </svg>
    );
  }

  const Legend = ({ items }) => (
    <div className="ws-legend">
      {items.map((it, i) => (
        <span key={i} className="ws-legend-i">
          <span className="ws-legend-d" style={{ background: 'currentColor', opacity: Math.max(0.2, 1 - i * 0.22) }} />{it}
        </span>
      ))}
    </div>
  );
  const ChartBox = ({ legend, children }) => (
    <div className="ws-chartbox">
      <div className="ws-chartbox-c">{children}</div>
      {legend ? <Legend items={legend} /> : null}
    </div>
  );

  /* строка сводных чисел под графиком (мин/макс/медиана/…) — как в референсе */
  const StatRow = ({ stats }) => (
    <div className="ws-statrow">
      {(stats || []).map((s, i) => (
        <span key={i} className="ws-stat"><b>{s.v}</b>{s.label}</span>
      ))}
    </div>
  );
  /* производные статистики по числовому ряду */
  const seriesStats = (nums) => {
    const a = (nums || []).filter((v) => typeof v === 'number' && !isNaN(v));
    if (!a.length) return [];
    const sorted = [...a].sort((x, y) => x - y);
    const mid = Math.floor(sorted.length / 2);
    const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
    const rnd = (v) => (Math.round(v * 10) / 10);
    return [
      { label: 'точек', v: a.length },
      { label: 'мин', v: rnd(Math.min(...a)) },
      { label: 'макс', v: rnd(Math.max(...a)) },
      { label: 'медиана', v: rnd(median) },
    ];
  };

  /* ═══════════ атомы контента ═══════════ */

  /* Метрики отсортированы по важности. Показываем ровно столько, сколько влезает:
     первая — крупная, остальные — компактными строками. Ничего не режется многоточием
     от нехватки высоты, лишнее просто не рендерится. */
  /* семантический тон метрики (как в прежнем lk-Kpi): состояние читается цветом
     плашки, а не только текстом. 'theme' — в тон цвета группы (--ws-accent-hi). */
  /* глиф подмешан к цвету текста: чистые бренд-тона на светлой плашке давали
     2–2.9:1 и «выцветали» (нужно ≥3:1 для нетекстовой графики). */
  const TONES = {
    theme: ['color-mix(in srgb, var(--ws-accent-hi, var(--foreground)) 14%, transparent)', 'color-mix(in srgb, var(--ws-accent-hi, var(--foreground)) 55%, var(--ws-fg, var(--foreground)))'],
    ink: ['var(--ws-track, var(--secondary))', 'var(--ws-fg, var(--foreground))'],
    success: ['var(--success-soft, #e3f3ea)', 'color-mix(in srgb, var(--success-strong, #1a7f5a) 84%, var(--ws-fg, var(--foreground)))'],
    warning: ['var(--warning-soft, #fdf1dc)', 'color-mix(in srgb, var(--warning-strong, #a76a10) 82%, var(--ws-fg, var(--foreground)))'],
    danger: ['color-mix(in srgb, var(--destructive, #b3261e) 12%, transparent)', 'color-mix(in srgb, var(--destructive, #b3261e) 72%, var(--ws-fg, var(--foreground)))'],
    info: ['color-mix(in srgb, var(--info, #2f5fa6) 13%, transparent)', 'color-mix(in srgb, var(--info, #2f5fa6) 82%, var(--ws-fg, var(--foreground)))'],
  };
  const toneOf = (p) => {
    if (!p) return 'theme';
    if (p.tone) return p.tone;
    if (p.delta) return p.delta.good === false ? 'danger' : 'success';
    return 'theme';
  };
  /* стиль плашки по первой метрике — используется глифом карточки в ops-field,
     чтобы иконка была ОДНА и несла состояние (дубль глифа убран) */
  const toneStyle = (p) => {
    const [bg, fg] = TONES[toneOf(p)] || TONES.theme;
    return { background: bg, color: fg };
  };

  /* Тип значения строки. Все 105 виджетов рендерились одной болванкой «label →
     value», поэтому доля, статус и счётчик выглядели одинаково. Здесь значение
     распознаётся, и карточка показывает СМЫСЛ: долю — полосой, статус — бейджем. */
  const ROW_STATUS = [
    { re: /^(подписан|согласован|оплачен|готов|закрыт|принят|выполнен|активен|в системе|сдан|есть)/i, tone: 'ok' },
    { re: /^(ожидает|на подпись|на согласовании|проверить|готовится|в работе|в пути|нужен|пинг|скоро|приёмка|приемка)/i, tone: 'wait' },
    { re: /^(просрочен|критичн|критично|возврат|блок|отклон|риск|угроз|срыв|нет |не на объекте|долг)/i, tone: 'bad' },
  ];
  /* Хвост единиц допускается: «1,2 / 4 млн ₽» — тоже доля, и полоса по ней
     читается быстрее, чем текст. Числа без дроби («30 млн ₽») долей не считаем. */
  const RE_PCT = /^(\d+(?:[.,]\d+)?)\s*%(?:\s*[^\d%\/]*)?$/;
  const RE_RATIO = /^(\d+(?:[.,]\d+)?)\s*\/\s*(\d+(?:[.,]\d+)?)(?:\s*[^\d%\/]*)?$/;
  const rowPct = (v) => {
    const s = String(v == null ? '' : v).trim();
    let m = s.match(RE_PCT);
    if (m) return Math.max(0, Math.min(100, Number(m[1].replace(',', '.'))));
    m = s.match(RE_RATIO);
    if (m) {
      const a = Number(m[1].replace(',', '.')), b = Number(m[2].replace(',', '.'));
      return b > 0 ? Math.max(0, Math.min(100, Math.round((a / b) * 100))) : null;
    }
    return null;
  };
  const rowTone = (v) => {
    const s = String(v == null ? '' : v).trim();
    if (/^[✓✔]$/.test(s)) return 'ok';
    if (/^[✗✕✖x]$/i.test(s)) return 'bad';
    const hit = ROW_STATUS.find((x) => x.re.test(s));
    return hit ? hit.tone : null;
  };

  function MetricStack({ items, ch, spark, sparkH }) {
    if (!items || !items.length) return null;
    const [p, ...rest] = items;
    /* в S 1×1 двухстрочный заголовок оставляет телу ~49px — крупная цифра туда не влезает */
    const dense = (ch || 0) < 80;
    const mainPct = rowPct(p.v);
    const showMainBar = mainPct != null && !dense;
    const ROW = 25, HEAD = (dense ? 42 : 60) + (showMainBar ? 14 : 0);
    const sparkPx = spark ? (sparkH || 48) : 0;
    const room = Math.max(0, (ch || 0) - HEAD - sparkPx - (p.delta ? 18 : 0));
    const n = Math.max(0, Math.min(rest.length, Math.floor(room / ROW)));
    return (
      <div className={'ws-mstack' + (dense ? ' is-dense' : '')}>
        <div>
          <div className="ws-metric">{p.v}{p.unit && <small>{p.unit}</small>}</div>
          {p.sub && <div className="ws-sub" title={p.sub}>{p.sub}</div>}
          {showMainBar && (
            <span className="ws-metric-bar" role="img" aria-label={'Прогресс ' + mainPct + '%'}>
              <i style={{ width: mainPct + '%' }} />
            </span>
          )}
          {p.delta && (
            <span className={'ws-delta ' + (p.delta.good === false ? 'is-down' : 'is-up')}>
              <Ic n={p.delta.dir === 'down' ? 'trending-down' : 'trending-up'} s={12} />{p.delta.v}
            </span>
          )}
        </div>
        {n > 0 && (
          <div className="ws-mrows">
            {rest.slice(0, n).map((m, i) => {
              const pct = rowPct(m.v);
              const tone = pct == null ? rowTone(m.v) : null;
              return (
                <div key={i} className="ws-mrow">
                  <span className="ws-mrow-l">{m.label}</span>
                  {pct != null && (
                    <span className="ws-mrow-bar" aria-hidden="true"><i style={{ width: pct + '%' }} /></span>
                  )}
                  <span className={'ws-mrow-v' + (tone ? ' ws-mtag is-' + tone : '')}>{m.v}</span>
                </div>
              );
            })}
          </div>
        )}
        {spark ? <div className={'ws-spark' + (sparkPx > 48 ? ' is-viz' : '')} style={sparkPx !== 48 ? { height: sparkPx } : undefined}>{spark}</div> : null}
      </div>
    );
  }

  const List = ({ rows }) => (
    <div className="ws-scroll">
      <div className="ws-list">
        {rows.map((r, i) => (
          <div key={i} className="ws-row">
            <div className="ws-row-main">
              <div className="ws-row-t" title={r.t}>{r.t}</div>
              {r.s ? <div className="ws-row-s">{r.s}</div> : null}
              {r.bar != null ? <div className="ws-bar-track"><span className="ws-bar-fill" style={{ width: r.bar + '%' }} /></div> : null}
            </div>
            <span className="ws-row-v">{r.v}</span>
          </div>
        ))}
      </div>
    </div>
  );
  const Tbl = ({ head, rows }) => (
    <div className="ws-scroll">
      <table className="ws-tbl">
        <thead><tr>{head.map((h, i) => <th key={i}>{h}</th>)}</tr></thead>
        <tbody>{rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
      </table>
    </div>
  );
  const Split = ({ left, right }) => (
    <div className="ws-split">
      <div className="ws-split-l">{left}</div>
      <div className="ws-split-r">{right}</div>
    </div>
  );

  /* ═══════════ представления ═══════════ */
  const VIEWS = [
    { id: 'auto', label: 'Авто', need: null },
    { id: 'metrics', label: 'Метрики', need: 'metrics' },
    { id: 'chart', label: 'График', need: 'chart' },
    { id: 'list', label: 'Список', need: 'list' },
    { id: 'table', label: 'Таблица', need: 'table' },
  ];

  function renderWidget(meta, ctx) {
    const { tier, tall, ch, view } = ctx;
    if (view === 'chart' && meta.chart) return meta.chart({ axis: tier >= 1, h: Math.max(70, Math.round(ch - 26)) });
    if (view === 'metrics' && meta.metrics) return <MetricStack items={meta.metrics()} ch={ch} />;
    if (view === 'list' && meta.list) return meta.list();
    if (view === 'table' && meta.table) return meta.table(tier >= 2);

    if (tier >= 3) return meta.table ? meta.table(true) : <MetricStack items={meta.metrics()} ch={ch} />;
    if (tier === 0) {
      const spark = tall && meta.chart ? meta.chart({ axis: false, h: 44, bare: true }) : null;
      return <MetricStack items={meta.metrics()} ch={ch} spark={spark} />;
    }
    if (tier === 1) return meta.list ? meta.list() : <MetricStack items={meta.metrics()} ch={ch} />;
    if (meta.table && meta.chart) return <Split left={meta.table(false)} right={meta.chart({ axis: true, h: 116 })} />;
    return meta.table ? meta.table(true) : meta.chart({ axis: true, h: 140 });
  }

  /* ═══════════ ВИДЖЕТЫ ОПЕРАЦИОННОГО ЦЕНТРА ═══════════ */
  const MONTHS = ['фев', 'мар', 'апр', 'май', 'июн', 'июл'];
  const DAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];

  function opsWidgets() {
    const O = window.OPS || {};
    const P = () => O.PROJECTS || [];
    const E = () => O.EXCEPTIONS || [];
    const T = () => O.TEAMS || [];
    const MSGS = [
      { who: '—', t: 'Когда финишная плитка в санузле?', at: '2 ч' },
      { who: '—', t: 'Отправьте акт по этапу 2', at: '5 ч' },
      { who: '—', t: 'Согласовал смету, жду договор', at: '1 дн' },
      { who: '—', t: 'Можно перенести замер?', at: '2 дн' },
    ];
    const SUPPLY = [
      { t: 'Керамогранит 60×60', s: 'KSH-044', v: 'в пути', eta: '9 июл', days: 1 },
      { t: 'Кабель ВВГнг 3×2.5', s: 'KSH-047', v: 'ожидает', eta: '11 июл', days: 3 },
      { t: 'Смеситель Grohe', s: 'KSH-061', v: 'ожидает', eta: '14 июл', days: 6 },
    ];
    const STAGES = [['Лиды', 18], ['Замеры', 11], ['Сметы', 7], ['Договоры', 3]];
    const TREND = [3.2, 4.1, 3.8, 5.2, 6.0, 6.4];
    const WEEK = [5, 4, 6, 3, 5, 2, 3];

    return {
      projects: {
        title: 'Проекты', icon: 'folder-kanban',
        metrics: () => {
          const ps = P();
          const avg = Math.round(ps.reduce((a, p) => a + p.pct, 0) / (ps.length || 1));
          return [
            { v: ps.length, sub: 'активных объектов' },
            { label: 'Средняя готовность', v: avg + '%' },
            { label: 'Общая смета', v: compact(ps.reduce((a, p) => a + p.estimate, 0)) + ' ₽' },
            { label: 'Прорабов', v: new Set(ps.map((p) => p.foreman)).size },
          ];
        },
        chart: (o) => <ChartBox legend={o.bare ? null : ['Готовность по объектам, %']}>
          <BarChart data={P().map((p) => p.pct)} labels={P().map((p) => p.code.slice(-3))} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={P().slice(0, 4).map((p) => ({ t: p.code + ' · ' + p.client, s: p.phase, v: p.pct + '%', bar: p.pct }))} />,
        table: (full) => (full
          ? <Tbl head={['Объект', 'Прораб', 'Стадия', 'Готовность', 'Смета']} rows={P().map((p) => [p.code + ' · ' + p.client, p.foreman, p.phase, p.pct + '%', compact(p.estimate) + ' ₽'])} />
          : <Tbl head={['Объект', 'Стадия', '%']} rows={P().map((p) => [p.code, p.phase, p.pct + '%'])} />),
      },
      finance: {
        title: 'Финансы', icon: 'wallet',
        metrics: () => {
          const ps = P();
          const spent = ps.reduce((a, p) => a + (p.spent || 0), 0);
          const est = ps.reduce((a, p) => a + (p.estimate || 0), 0);
          return [
            { v: compact(est - spent), unit: '₽', sub: 'остаток бюджетов', delta: { v: '+8%', dir: 'up' } },
            { label: 'Сметы', v: compact(est) + ' ₽' },
            { label: 'Освоено', v: compact(spent) + ' ₽' },
            { label: 'Освоение', v: Math.round(spent / (est || 1) * 100) + '%' },
          ];
        },
        chart: (o) => <ChartBox legend={o.bare ? null : ['Освоение, млн ₽']}>
          <LineChart data={TREND} labels={MONTHS} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={P().map((p) => ({ t: p.code, s: 'освоено ' + compact(p.spent) + ' ₽', v: Math.round(p.spent / p.estimate * 100) + '%', bar: Math.round(p.spent / p.estimate * 100) }))} />,
        table: (full) => (full
          ? <Tbl head={['Объект', 'Смета', 'Освоено', 'Остаток', '%']} rows={P().map((p) => [p.code, fmt(p.estimate) + ' ₽', fmt(p.spent) + ' ₽', fmt(p.estimate - p.spent) + ' ₽', Math.round(p.spent / p.estimate * 100) + '%'])} />
          : <Tbl head={['Объект', 'Освоено']} rows={P().map((p) => [p.code, Math.round(p.spent / p.estimate * 100) + '%'])} />),
      },
      tasks: {
        title: 'Задачи и сроки', icon: 'clock-alert',
        metrics: () => {
          const all = E();
          const ex = all.filter((e) => e.type === 'overdue' || e.severity === 'high');
          return [
            { v: ex.length, sub: 'требуют реакции', delta: { v: '+1', dir: 'up', good: false } },
            { label: 'Всего исключений', v: all.length },
            { label: 'Открыто за неделю', v: WEEK.reduce((a, b) => a + b, 0) },
            { label: 'Ответственных', v: new Set(all.map((e) => e.owner)).size },
          ];
        },
        chart: (o) => <ChartBox legend={o.bare ? null : ['Открыто за неделю']}>
          <LineChart data={WEEK} labels={DAYS} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={E().filter((e) => e.type === 'overdue' || e.severity === 'high').slice(0, 4).map((e) => ({ t: e.title, s: e.object, v: e.age }))} />,
        table: (full) => (full
          ? <Tbl head={['Проблема', 'Объект', 'Ответственный', 'Возраст']} rows={E().map((e) => [e.title, e.object, e.owner, e.age])} />
          : <Tbl head={['Проблема', 'Возраст']} rows={E().slice(0, 5).map((e) => [e.title, e.age])} />),
      },
      messages: {
        title: 'Сообщения клиентов', icon: 'message-circle',
        metrics: () => [
          { v: MSGS.length, sub: 'непрочитанных' },
          { label: 'Ждут больше суток', v: 2 },
          { label: 'Среднее время ответа', v: '3 ч' },
        ],
        chart: (o) => <ChartBox legend={o.bare ? null : ['Обращений за неделю']}>
          <BarChart data={[3, 5, 2, 6, 4, 1, 4]} labels={DAYS} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={MSGS.slice(0, 4).map((m) => ({ t: m.who, s: m.t, v: m.at }))} />,
        table: () => <Tbl head={['Клиент', 'Сообщение', 'Когда']} rows={MSGS.map((m) => [m.who, m.t, m.at])} />,
      },
      crews: {
        title: 'Загрузка бригад', icon: 'users-round',
        metrics: () => {
          const ts = T();
          return [
            { v: O.TEAM_LOAD_TOTAL || 71, unit: '%', sub: 'средняя загрузка' },
            { label: 'Бригад', v: ts.length },
            { label: 'Человек', v: ts.reduce((a, t) => a + (t.crew || []).length, 0) },
            { label: 'Часов в неделю', v: ts.reduce((a, t) => a + (t.hours || 0), 0) },
          ];
        },
        chart: (o) => <ChartBox legend={o.bare ? null : T().map((t) => t.foreman.split(' ')[0])}>
          <DonutChart segments={T().map((t) => ({ v: t.load || 0 }))} center={(O.TEAM_LOAD_TOTAL || 71) + '%'} sub="средняя" size={o.h > 90 ? 128 : 92} />
        </ChartBox>,
        list: () => <List rows={T().map((t) => ({ t: t.foreman, s: t.specialty, v: (t.load || 0) + '%', bar: t.load || 0 }))} />,
        table: (full) => (full
          ? <Tbl head={['Бригадир', 'Специализация', 'Человек', 'Часы', 'Загрузка']} rows={T().map((t) => [t.foreman, t.specialty, (t.crew || []).length, t.hours || 0, (t.load || 0) + '%'])} />
          : <Tbl head={['Бригадир', 'Загрузка']} rows={T().map((t) => [t.foreman, (t.load || 0) + '%'])} />),
      },
      procurement: {
        title: 'Закупки', icon: 'package',
        metrics: () => [
          { v: SUPPLY.length, sub: 'ожидают поставки' },
          { label: 'В пути', v: SUPPLY.filter((s) => s.v === 'в пути').length },
          { label: 'Ближайшая', v: SUPPLY[0].eta },
        ],
        chart: (o) => <ChartBox legend={o.bare ? null : ['Дней до поставки']}>
          <BarChart data={SUPPLY.map((s) => s.days)} labels={SUPPLY.map((s) => s.s.slice(-3))} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={SUPPLY.map((i) => ({ t: i.t, s: i.s, v: i.eta }))} />,
        table: () => <Tbl head={['Позиция', 'Объект', 'Статус', 'ETA']} rows={SUPPLY.map((i) => [i.t, i.s, i.v, i.eta])} />,
      },
      funnel: {
        title: 'Воронка продаж', icon: 'filter',
        metrics: () => [
          { v: STAGES[3][1], sub: 'договоров за месяц' },
          { label: 'Лидов', v: STAGES[0][1] },
          { label: 'Замеров', v: STAGES[1][1] },
          { label: 'Конверсия', v: Math.round(STAGES[3][1] / STAGES[0][1] * 100) + '%' },
        ],
        chart: (o) => <ChartBox legend={o.bare ? null : ['Лиды → договоры']}>
          <BarChart data={STAGES.map((s) => s[1])} labels={STAGES.map((s) => s[0])} h={o.h} showAxis={o.axis} />
        </ChartBox>,
        list: () => <List rows={STAGES.map((s, i) => ({ t: s[0], s: i ? 'конверсия ' + Math.round(s[1] / STAGES[i - 1][1] * 100) + '%' : 'вход', v: s[1] }))} />,
        table: () => <Tbl head={['Этап', 'Кол-во', 'Конверсия']} rows={STAGES.map((s, i) => [s[0], s[1], i ? Math.round(s[1] / STAGES[i - 1][1] * 100) + '%' : '—'])} />,
      },
    };
  }

  const OPS_PRESETS = {
    'ops-morning': { name: 'Утренний обзор', scale: 1.0, widgets: [
      { id: 'tasks', size: 'M', h: 2 }, { id: 'projects', size: 'M', h: 2 },
      { id: 'messages', size: 'S', h: 1 }, { id: 'crews', size: 'S', h: 1 },
      { id: 'finance', size: 'L', h: 2 },
    ] },
    'ops-finance': { name: 'Финансы', scale: 1.0, widgets: [
      { id: 'finance', size: 'L', h: 2 }, { id: 'funnel', size: 'M', h: 2 },
      { id: 'projects', size: 'M', h: 2 }, { id: 'procurement', size: 'S', h: 1 }, { id: 'tasks', size: 'S', h: 1 },
    ] },
    'ops-projects': { name: 'Проекты', scale: 1.0, widgets: [
      { id: 'projects', size: 'L', h: 2 }, { id: 'tasks', size: 'M', h: 2 },
      { id: 'crews', size: 'M', h: 2 }, { id: 'procurement', size: 'S', h: 1 }, { id: 'messages', size: 'S', h: 1 },
    ] },
    'ops-crews': { name: 'Загрузка бригад', scale: 1.0, widgets: [
      { id: 'crews', size: 'L', h: 2 }, { id: 'tasks', size: 'M', h: 2 }, { id: 'projects', size: 'M', h: 2 },
    ] },
  };

  /* ═══════════ карточка ═══════════ */
  function WsCard({ w, meta, cols, onSize, onHeight, onShape, onView, onColor, onExpand, onGripDown, onAdd, onRemove, dragging }) {
    const bodyRef = R.useRef(null);
    const popRef = R.useRef(null);
    const [box, setBox] = R.useState({ cw: 400, ch: 200 });
    const [pop, setPop] = R.useState(null);

    const togglePop = (e) => {
      if (pop) { setPop(null); return; }
      const r = e.currentTarget.getBoundingClientRect();
      const width = Math.min(320, window.innerWidth - 24);
      const height = 470;
      const left = Math.max(12, Math.min(r.right - width, window.innerWidth - width - 12));
      const top = window.innerHeight - r.bottom >= height + 12 ? r.bottom + 8 : Math.max(12, r.top - height - 8);
      setPop({ left, top });
    };

    /* Меряем тело, а не карточку: тир зависит от контентной ширины,
       а число метрик — от контентной высоты. Первый замер синхронный:
       в скрытой вкладке ResizeObserver не вызывается, и карточка застряла
       бы на стартовом размере. */
    R.useLayoutEffect(() => {
      const el = bodyRef.current; if (!el) return undefined;
      const read = () => setBox({ cw: el.clientWidth, ch: el.clientHeight });
      read();
      if (!window.ResizeObserver) return undefined;
      const ro = new ResizeObserver(read); ro.observe(el);
      return () => ro.disconnect();
    }, []);
    R.useEffect(() => {
      if (!pop) return;
      const away = (e) => { if (popRef.current && !popRef.current.contains(e.target)) setPop(null); };
      const esc = (e) => { if (e.key === 'Escape') setPop(null); };
      const close = () => setPop(null);
      document.addEventListener('mousedown', away); document.addEventListener('keydown', esc);
      window.addEventListener('resize', close); window.addEventListener('scroll', close, true);
      return () => { document.removeEventListener('mousedown', away); document.removeEventListener('keydown', esc); window.removeEventListener('resize', close); window.removeEventListener('scroll', close, true); };
    }, [pop]);

    if (!meta) return null;
    const view = w.view || 'auto';
    const ctx = { tier: box.cw < 300 ? 0 : box.cw < 670 ? 1 : 2, tall: box.ch >= 150, ch: box.ch, cw: box.cw, view };

    /* тащим за всю шапку: отдельная ручка съедала ширину заголовка в S-карточке */
    const headDown = (e) => { if (e.target.closest('.ws-tools')) return; onGripDown(e, w.id); };

    return (
      <section className={'ws-card' + (dragging ? ' is-dragging' : '')}
        data-size={w.size} data-shape={w.shape || 'wide'} data-h={w.h || DEFAULT_H[w.size]} data-wid={w.id}
        data-color={w.color || 'default'} data-color-mode={w.colorMode || 'fill'}
        style={{ ...(cardVars(w.color, w.colorMode) || {}), ...tileStyle(w, cols) }} role="group" aria-label={meta.title}>

        <div className="ws-head" onPointerDown={headDown} title="Перетащите, чтобы переставить">
          <span className="ws-title">{meta.title}</span>
          <button className="ws-kb-grip" aria-label={'Переместить «' + meta.title + '»'}
            onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onGripDown(e, w.id, true); } }}>Переместить</button>
          <span className="ws-tools">
            <button className="ws-icon-btn" aria-label={'Развернуть «' + meta.title + '»'} title="Развернуть" onClick={() => onExpand(w.id)}>
              <Ic n="maximize-2" s={14} />
            </button>
            <button className="ws-icon-btn" aria-expanded={Boolean(pop)} aria-haspopup="dialog"
              aria-label={'Настройки «' + meta.title + '»'} title="Настройки блока" onClick={togglePop}>
              <Ic n="more-horizontal" s={15} />
            </button>
          </span>
        </div>

        {/* backdrop-filter карточки делает её containing block для fixed-потомков,
            поэтому popover уходит порталом в body — иначе обрежется по границам S. */}
        {pop && RD.createPortal((
          <div className="ws-pop" ref={popRef} role="dialog" aria-label={'Настройки «' + meta.title + '»'} style={pop}>
            <div className="ws-pop-row">
              <span className="ws-pop-lbl">Ширина</span>
              <div className="ws-seg" role="group" aria-label="Ширина">
                {['S', 'M', 'L'].map((s) => <button key={s} aria-pressed={w.size === s} onClick={() => onSize(w.id, s)}>{s}</button>)}
              </div>
            </div>
            <div className="ws-pop-row">
              <span className="ws-pop-lbl">Высота (строк)</span>
              <div className="ws-seg" role="group" aria-label="Высота">
                {[1, 2, 3].map((n) => <button key={n} aria-pressed={(w.h || DEFAULT_H[w.size]) === n} onClick={() => onHeight(w.id, n)}>{n}</button>)}
              </div>
            </div>
            <div className="ws-pop-row">
              <span className="ws-pop-lbl">Форма</span>
              <div className="ws-seg ws-shape" role="group" aria-label="Форма карточки">
                <button aria-pressed={(w.shape || 'wide') === 'wide'} onClick={() => onShape(w.id, 'wide')}><Ic n="columns-2" s={13} />Шире</button>
                <button aria-pressed={w.shape === 'vertical'} onClick={() => onShape(w.id, 'vertical')}><Ic n="rows-2" s={13} />Вертикально</button>
              </div>
              <span className="ws-pop-hint">Две S друг над другом равны M по высоте.</span>
            </div>
            <div className="ws-pop-row">
              <span className="ws-pop-lbl">Содержимое</span>
              <div className="ws-seg ws-views" role="group" aria-label="Содержимое">
                {VIEWS.filter((v) => !v.need || meta[v.need]).map((v) => (
                  <button key={v.id} aria-pressed={view === v.id} onClick={() => onView(w.id, v.id)}>{v.label}</button>
                ))}
              </div>
              <span className="ws-pop-hint">«Авто» подбирает представление под размер карточки.</span>
            </div>
            <div className="ws-pop-row">
              <span className="ws-pop-lbl">Цвет</span>
              <GroupedPalette
                value={w.color || 'default'}
                onChange={(cid) => onColor(w.id, cid || 'default')}
              />
              <span className="ws-pop-hint">Контур карточки, без заливки фона.</span>
            </div>
            <div className="ws-pop-actions">
              <button className="ws-pop-act" onClick={() => { setPop(null); onAdd(w.id, 'right'); }}><Ic n="columns-2" s={14} />Добавить справа</button>
              <button className="ws-pop-act" onClick={() => { setPop(null); onAdd(w.id, 'below'); }}><Ic n="rows-2" s={14} />Добавить точно снизу</button>
              <button className="ws-pop-act danger" onClick={() => { setPop(null); onRemove(w.id); }}><Ic n="trash-2" s={14} />Убрать блок</button>
            </div>
          </div>
        ), document.body)}

        <div className="ws-body" ref={bodyRef}>{renderWidget(meta, ctx)}</div>
      </section>
    );
  }

  /* ═══════════ поле ═══════════ */
  function Workspace({ widgets: WIDGETS_PROP, presets: PRESETS_PROP, storeKey, cols }) {
    const WIDGETS = R.useMemo(() => WIDGETS_PROP || opsWidgets(), [WIDGETS_PROP]);
    const PRESETS = PRESETS_PROP || OPS_PRESETS;
    const KEY = storeKey || 'ops_workspace_v2';
    const COLS = cols || GRID_COLS;
    const firstPreset = Object.keys(PRESETS)[0];

    const loadStore = () => {
      try { const raw = localStorage.getItem(KEY); if (raw) return JSON.parse(raw); } catch (e) {}
      return { current: firstPreset, custom: {} };
    };
    const saveStore = (s) => { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) {} };

    const [store, setStore] = R.useState(loadStore);
    const comps = { ...PRESETS, ...(store.custom || {}) };
    const compId = comps[store.current] ? store.current : firstPreset;
    const comp = comps[compId];

    const [widgets, setWidgets] = R.useState(() => layoutWidgets(comp.widgets, null, COLS));
    const [scale, setScale] = R.useState(comp.scale || 1);
    const [modal, setModal] = R.useState(null);
    const [drag, setDrag] = R.useState(null);
    const [lib, setLib] = R.useState(false);
    const [undo, setUndo] = R.useState(null);
    const gridRef = R.useRef(null);
    const libRef = R.useRef(null);
    const saveT = R.useRef(null);
    const undoT = R.useRef(null);

    const persist = (ws, sc) => {
      clearTimeout(saveT.current);
      const s = sc != null ? sc : scale;
      saveT.current = setTimeout(() => {
        const next = { ...store, current: compId, custom: { ...(store.custom || {}), [compId]: { name: comp.name, scale: s, widgets: ws } } };
        setStore(next); saveStore(next);
      }, 600);
    };

    const applyComp = (id) => {
      const c = comps[id]; if (!c) return;
      setWidgets(layoutWidgets(c.widgets, null, COLS));
      setScale(c.scale || 1);
      const next = { ...store, current: id }; setStore(next); saveStore(next);
    };

    const patch = (id, p) => setWidgets((ws) => { const n = layoutWidgets(ws.map((w) => (w.id === id ? { ...w, ...p } : w)), null, COLS); persist(n); return n; });
    const setSize = (id, size) => setWidgets((ws) => { const n = layoutWidgets(ws.map((w) => (w.id === id ? { ...w, size, h: DEFAULT_H[size] } : w)), null, COLS); persist(n); return n; });
    const setHeight = (id, h) => patch(id, { h });
    const setShape = (id, shape) => patch(id, { shape: shape === 'wide' ? undefined : shape });
    const setView = (id, view) => patch(id, { view });
    const setColor = (id, color) => {
      const cid = color === 'default' ? null : color;
      const accent = cid && ['sand', 'linen', 'mint', 'green', 'amber', 'sky'].indexOf(cid) >= 0;
      patch(id, { color: cid || 'default', colorMode: accent ? 'outline' : null });
    };

    /* удаление обратимо: тост «Вернуть» + блок всегда доступен в библиотеке */
    const removeWidget = (id) => setWidgets((ws) => {
      const idx = ws.findIndex((w) => w.id === id);
      if (idx < 0) return ws;
      const removed = ws[idx];
      const n = layoutWidgets(ws.filter((w) => w.id !== id), null, COLS);
      persist(n);
      clearTimeout(undoT.current);
      setUndo({ w: removed, idx, title: (WIDGETS[removed.src || removed.id] || {}).title });
      undoT.current = setTimeout(() => setUndo(null), 8000);
      return n;
    });
    const undoRemove = () => {
      if (!undo) return;
      const u = undo;
      setWidgets((ws) => { const n = [...ws]; n.splice(Math.min(u.idx, n.length), 0, u.w); const laid = layoutWidgets(n, null, COLS); persist(laid); return laid; });
      clearTimeout(undoT.current); setUndo(null);
    };

    const addById = (srcId, afterId, dir) => setWidgets((ws) => {
      const laid = layoutWidgets(ws, null, COLS);
      const dup = laid.some((w) => (w.src || w.id) === srcId);
      const id = dup ? srcId + '-' + Date.now().toString(36) : srcId;
      const source = afterId && laid.find((w) => w.id === afterId);
      const sourceShape = source && geometry(source, COLS);
      const item = { id, src: srcId, size: 'S', h: 1 };
      if (source && sourceShape) {
        item.x = dir === 'below' ? source.x : source.x + sourceShape.w;
        item.y = dir === 'below' ? source.y + sourceShape.h : source.y;
        if (item.x >= COLS) { item.x = 0; item.y = source.y + sourceShape.h; }
      }
      const at = afterId ? laid.findIndex((w) => w.id === afterId) : laid.length - 1;
      const n = [...laid]; n.splice(at + 1, 0, item);
      const next = layoutWidgets(n, null, COLS); persist(next); return next;
    });
    const addNeighbour = (afterId, dir) => {
      const used = new Set(widgets.map((w) => w.src || w.id));
      const free = Object.keys(WIDGETS).find((k) => !used.has(k)) || Object.keys(WIDGETS)[0];
      addById(free, afterId, dir);
    };

    const bumpScale = (dir) => setScale((s) => {
      const i = SCALES.indexOf(s); const ni = Math.min(SCALES.length - 1, Math.max(0, (i < 0 ? 2 : i) + dir));
      const v = SCALES[ni]; persist(widgets, v); return v;
    });
    const setScaleVal = (v) => { setScale(v); persist(widgets, v); };

    /* Геометрия клетки читается из рассчитанного grid, а не из констант:
       ширина колонки зависит от --ws-scale и от горизонтального скролла холста. */
    const gridMetrics = () => {
      const grid = gridRef.current;
      if (!grid) return null;
      const cs = getComputedStyle(grid);
      const cols = cs.gridTemplateColumns.split(' ').map(parseFloat).filter((n) => !isNaN(n));
      if (cols.length < 2) return null; /* одноколоночный режим: клеток нет */
      const gap = parseFloat(cs.columnGap) || 0;
      const rowGap = parseFloat(cs.rowGap) || gap;
      const rowH = parseFloat(cs.gridAutoRows) || 122;
      const r = grid.getBoundingClientRect();
      return { originX: r.left - grid.scrollLeft, originY: r.top, colStep: cols[0] + gap, rowStep: rowH + rowGap, cols: cols.length };
    };
    /* клетка под верхним-левым углом призрака, а не под курсором:
       карточка должна лечь туда, где её видно. */
    const cellFromGhost = (left, top, shape) => {
      const m = gridMetrics();
      if (!m) return null;
      return {
        x: Math.max(0, Math.min(m.cols - shape.w, Math.round((left - m.originX) / m.colStep))),
        y: Math.max(0, Math.round((top - m.originY) / m.rowStep)),
      };
    };

    const placeAt = (id, cell) => setWidgets((ws) => {
      const cur = ws.find((w) => w.id === id);
      if (!cur || !cell || (cur.x === cell.x && cur.y === cell.y)) return ws;
      const n = ws.map((w) => (w.id === id ? { ...w, x: cell.x, y: cell.y } : w));
      const next = layoutWidgets(n, id, COLS); persist(next); return next;
    });
    /* одноколоночный режим: клеток нет, работает только порядок следования */
    const reorder = (fromId, toId) => setWidgets((ws) => {
      if (!toId || fromId === toId) return ws;
      const n = [...ws];
      const fi = n.findIndex((w) => w.id === fromId), ti = n.findIndex((w) => w.id === toId);
      if (fi < 0 || ti < 0) return ws;
      const [it] = n.splice(fi, 1);
      n.splice(ti, 0, it);
      const next = layoutWidgets(n.map((w) => ({ ...w, x: undefined, y: undefined })), null, COLS);
      persist(next); return next;
    });
    const nudge = (id, dx, dy) => setWidgets((ws) => {
      const cur = ws.find((w) => w.id === id);
      if (!cur) return ws;
      const shape = geometry(cur, COLS);
      const cell = { x: Math.max(0, Math.min(COLS - shape.w, (cur.x || 0) + dx)), y: Math.max(0, (cur.y || 0) + dy) };
      const n = ws.map((w) => (w.id === id ? { ...w, ...cell } : w));
      const next = layoutWidgets(n, id, COLS); persist(next); return next;
    });

    R.useEffect(() => {
      const h = (e) => {
        if (!(e.ctrlKey || e.metaKey)) return;
        if (e.key === '=' || e.key === '+') { e.preventDefault(); bumpScale(1); }
        else if (e.key === '-') { e.preventDefault(); bumpScale(-1); }
        else if (e.key === '0') { e.preventDefault(); setScaleVal(1); }
      };
      window.addEventListener('keydown', h);
      return () => window.removeEventListener('keydown', h);
    }, [widgets, scale]);

    R.useEffect(() => {
      if (!lib) return;
      const away = (e) => { if (libRef.current && !libRef.current.contains(e.target)) setLib(false); };
      document.addEventListener('mousedown', away);
      return () => document.removeEventListener('mousedown', away);
    }, [lib]);

    /* DnD: карточка кладётся в клетку сетки под собой, а не меняется местами с той,
       на которую наведён курсор — иначе две S, стоящие рядом, так и останутся рядом. */
    const onGripDown = (e, id, keyboard) => {
      if (keyboard) {
        const onKey = (ev) => {
          const step = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] }[ev.key];
          if (step) { ev.preventDefault(); nudge(id, step[0], step[1]); return; }
          if (ev.key === ' ' || ev.key === 'Enter' || ev.key === 'Escape') { ev.preventDefault(); window.removeEventListener('keydown', onKey, true); setDrag(null); }
        };
        setDrag({ id, kb: true });
        window.addEventListener('keydown', onKey, true);
        return;
      }
      e.preventDefault();
      const srcEl = e.currentTarget.closest('.ws-card');
      const box = srcEl.getBoundingClientRect();
      const off = { x: e.clientX - box.left, y: e.clientY - box.top };
      const shape = geometry(widgets.find((w) => w.id === id) || {}, COLS);
      setDrag({ id, x: box.left, y: box.top, w: box.width, h: box.height, off, shape, cell: cellFromGhost(box.left, box.top, shape) });

      const cardUnder = (px, py) => {
        const els = [...gridRef.current.querySelectorAll('.ws-card')];
        const hit = els.find((el) => { const r = el.getBoundingClientRect(); return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom; });
        return hit ? hit.dataset.wid : null;
      };
      const onMove = (ev) => {
        const left = ev.clientX - off.x, top = ev.clientY - off.y;
        const cell = cellFromGhost(left, top, shape);
        setDrag((d) => (d ? { ...d, x: left, y: top, cell, overId: cell ? null : cardUnder(ev.clientX, ev.clientY) } : d));
      };
      const onUp = () => {
        setDrag((d) => {
          if (!d) return null;
          if (d.cell) placeAt(d.id, d.cell);
          else if (d.overId) reorder(d.id, d.overId);
          return null;
        });
        window.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
      };
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
    };

    const saveAs = () => {
      const name = prompt('Название композиции:', 'Моя раскладка');
      if (!name) return;
      const id = 'user-' + Date.now().toString(36);
      const next = { ...store, current: id, custom: { ...(store.custom || {}), [id]: { name, scale, widgets } } };
      setStore(next); saveStore(next);
    };
    const resetComp = () => {
      const c = PRESETS[compId] || PRESETS[firstPreset];
      setWidgets(layoutWidgets(c.widgets, null, COLS)); setScale(c.scale || 1);
      const next = { ...store, custom: { ...(store.custom || {}) } }; delete next.custom[compId];
      setStore(next); saveStore(next);
    };

    const usedSrc = new Set(widgets.map((w) => w.src || w.id));
    const removedIds = Object.keys(WIDGETS).filter((k) => !usedSrc.has(k));
    const modalW = modal && widgets.find((w) => w.id === modal);
    const modalMeta = modal && WIDGETS[(modalW && modalW.src) || modal];

    return (
      <div className="ws" style={{ '--ws-scale': scale, '--ws-cols': COLS }}>
        <div className="ws-bar" role="group" aria-label="Управление рабочим полем">
          <span className="ws-bar-lbl">Композиция</span>
          <select className="ws-select" value={compId} onChange={(e) => applyComp(e.target.value)} aria-label="Композиция">
            {Object.entries(comps).map(([id, c]) => <option key={id} value={id}>{c.name}</option>)}
          </select>
          <button className="ws-btn" onClick={saveAs}><Ic n="save" s={13} />Сохранить как…</button>
          <button className="ws-btn" onClick={resetComp} title="Вернуть исходную раскладку"><Ic n="rotate-ccw" s={13} />Сброс</button>

          <span className="ws-bar-sep" />

          <button className="ws-btn ws-btn--primary" aria-expanded={lib} onClick={() => setLib((v) => !v)}>
            <Ic n="layout-grid" s={13} />Блоки
            {removedIds.length > 0 && <span className="ws-badge">{removedIds.length}</span>}
          </button>
          {lib && (
            <div className="ws-lib" ref={libRef} role="menu">
              {removedIds.length > 0 && (
                <React.Fragment>
                  <div className="ws-lib-h">Убранные блоки — вернуть на поле</div>
                  {removedIds.map((id) => (
                    <button key={id} className="ws-lib-i is-removed" onClick={() => { addById(id); setLib(false); }}>
                      <Ic n={WIDGETS[id].icon} s={15} />{WIDGETS[id].title}
                      <span className="tag"><Ic n="undo-2" s={12} />вернуть</span>
                    </button>
                  ))}
                </React.Fragment>
              )}
              <div className="ws-lib-h">Все блоки — добавить копию</div>
              {Object.entries(WIDGETS).filter(([id]) => usedSrc.has(id)).map(([id, m]) => (
                <button key={id} className="ws-lib-i" onClick={() => { addById(id); setLib(false); }}>
                  <Ic n={m.icon} s={15} />{m.title}<span className="tag">на поле</span>
                </button>
              ))}
            </div>
          )}

          <span className="ws-bar-sep" />

          <span className="ws-bar-lbl">Масштаб</span>
          <button className="ws-step" onClick={() => bumpScale(-1)} aria-label="Уменьшить масштаб">−</button>
          <input className="ws-slider" type="range" min="0" max={SCALES.length - 1} step="1"
            value={Math.max(0, SCALES.indexOf(scale))} onChange={(e) => setScaleVal(SCALES[+e.target.value])} aria-label="Масштаб поля" />
          <button className="ws-step" onClick={() => bumpScale(1)} aria-label="Увеличить масштаб">+</button>
          <span className="ws-scale-val">{Math.round(scale * 100)}%</span>
        </div>

        <div className="ws-grid" ref={gridRef}>
          {widgets.map((w) => (
            <WsCard key={w.id} w={w} meta={WIDGETS[w.src || w.id]} cols={COLS} dragging={drag && drag.id === w.id}
              onSize={setSize} onHeight={setHeight} onShape={setShape} onView={setView} onColor={setColor}
              onExpand={setModal} onGripDown={onGripDown} onAdd={addNeighbour} onRemove={removeWidget} />
          ))}
          {drag && drag.cell && (
            <div className="ws-ph" aria-hidden="true"
              style={{ gridColumn: `${drag.cell.x + 1} / span ${drag.shape.w}`, gridRow: `${drag.cell.y + 1} / span ${drag.shape.h}` }} />
          )}
        </div>

        {drag && !drag.kb && (() => {
          const w = widgets.find((x) => x.id === drag.id);
          const meta = w && WIDGETS[w.src || w.id];
          if (!meta) return null;
          return (
            <div className="ws-ghost" aria-hidden="true"
              style={{ left: drag.x, top: drag.y, width: drag.w, minHeight: drag.h, '--ws-scale': scale, ...(cardVars(w.color) || {}) }}>
              <span className="ws-title">{meta.title}</span>
            </div>
          );
        })()}

        {undo && (
          <div className="ws-toast" role="status">
            <span>Блок «{undo.title}» убран</span>
            <button onClick={undoRemove}>Вернуть</button>
          </div>
        )}

        {modal && modalMeta && (
          <React.Fragment>
            <div className="ws-modal-ov" onClick={() => setModal(null)} />
            <div className="ws-modal" role="dialog" aria-modal="true" aria-label={modalMeta.title}>
              <div className="ws-modal-head">
                <span className="ws-modal-ic"><Ic n={modalMeta.icon} s={17} /></span>
                <span className="ws-modal-title">{modalMeta.title}</span>
                <button className="ws-icon-btn" onClick={() => setModal(null)} aria-label="Закрыть"><Ic n="x" s={17} /></button>
              </div>
              <div className="ws-modal-body">{renderWidget(modalMeta, { tier: 3, tall: true, ch: 480, cw: 900, view: 'auto' })}</div>
            </div>
          </React.Fragment>
        )}
      </div>
    );
  }

  Object.assign(window, {
    OpsWorkspace: Workspace,
    /* конструктор наборов для раскатки на ЛК + примитивы для обвязки секций */
    WorkspaceKit: {
      LineChart, BarChart, DonutChart, ChartBox, Legend, StatRow, seriesStats, List, Tbl, Split, MetricStack, FillBox, toneStyle, compact, fmt,
      PALETTE, PALETTE_GROUPS, cardVars, swatchStyle, GroupedPalette, hex2rgb,
    },
  });
})();
