/* ops-field.jsx — ЛК как одно поле из вложенных виджетов (прототип).
   Узлы холста: свободный виджет | группа (блок с вложенными виджетами).
   Укладка — CSS Grid 6 колонок, span = число колонок; dense заполняет дыры.
   Виджеты рисуются примитивами window.WorkspaceKit; данные — window.OPS. */
(function () {
  const R = React;
  const RD = ReactDOM;
  const K = window.WorkspaceKit;
  const Ic = window.Ic;
  const O = window.OPS || {};
  if (!K || !Ic || !window.FieldKit) { return; }
  /* сетка и визуальные пикеры — из общего FieldKit (опора B, KSH-355/356) */
  const { nodeCols, tierOfCols, packWithGaps, ColPicker, RowPicker } = window.FieldKit;

  const FIELD_COLS = 6;
  const ROW = 132;
  const MASONRY_ROW = 8; /* мелкий трек grid-auto-rows для masonry-упаковки (KSH-367) */
  const widgetFixedPx = (fixedH) => (fixedH ? fixedH * ROW + (fixedH - 1) * 10 : null);
  const STORE = 'ops_field_v1';
  const uid = () => 'n' + Math.random().toString(36).slice(2, 8);

  /* ── каталог виджетов (по категориям) ── */
  const P = () => O.PROJECTS || [];
  const E = () => O.EXCEPTIONS || [];
  const T = () => O.TEAMS || [];
  const C = () => O.CLIENTS || [];
  const MONTHS = ['фев', 'мар', 'апр', 'май', 'июн', 'июл'];
  const DAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];

  const chart = (c, o) => {
    const legend = o.bare ? null : c.legend;
    if (c.type === 'donut') return <K.ChartBox legend={legend}><K.DonutChart segments={c.data.map((v) => ({ v }))} center={c.center} sub={c.sub} size={o.h > 90 ? 120 : 88} /></K.ChartBox>;
    const Ch = c.type === 'bar' ? K.BarChart : K.LineChart;
    return <K.ChartBox legend={legend}><Ch data={c.data} labels={c.labels} h={o.h} showAxis={o.axis} /></K.ChartBox>;
  };
  const def = (d) => {
    const w = { title: d.title, icon: d.icon, cat: d.cat };
    if (d.metrics) w.metrics = d.metrics;
    if (d.chart) w.chart = d.chart;
    if (d.chartPrev) w.chartPrev = d.chartPrev;
    if (d.list) w.list = d.list;
    if (d.table) w.table = d.table;
    if (d.rows) w.rows = d.rows;
    if (d.render) w.render = d.render;
    if (d.full) w.full = d.full;
    if (d.zone) w.zone = true;
    return w;
  };

  const BUILTIN_WIDGETS = {
    funnel: def({
      title: 'Воронка продаж', icon: 'filter', cat: 'Продажи',
      metrics: () => [{ v: 3, sub: 'договора за месяц' }, { label: 'Лиды', v: 18 }, { label: 'Замеры', v: 11 }, { label: 'Конверсия', v: '17%' }],
      chart: () => [['Лиды', 18], ['Замеры', 11], ['Сметы', 7], ['Договоры', 3]],
      list: () => [['Лиды', 18], ['Замеры', 11], ['Сметы', 7], ['Договоры', 3]],
    }),
    leads: def({
      title: 'Лиды', icon: 'user-plus', cat: 'Продажи',
      metrics: () => [{ v: 18, sub: 'лидов за месяц', delta: { v: '+4', dir: 'up' } }, { label: 'Новых сегодня', v: 3 }, { label: 'Без ответа', v: 2 }],
      chart: () => [8, 11, 9, 14, 16, 18],
      chartPrev: () => [10, 9, 12, 11, 13, 14],
    }),
    clients: def({
      title: 'Клиенты', icon: 'users', cat: 'Клиенты',
      metrics: () => [{ v: (C().length || 12), sub: 'активных клиентов' }, { label: 'Новых за месяц', v: 4 }, { label: 'Без касания >7 дн', v: 2 }],
      rows: () => (C().length ? C() : [{ name: '—', s: 'KSH-047' }, { name: '—', s: 'KSH-044' }, { name: '—', s: 'лид' }]),
    }),
    feed: def({
      title: 'Лента', icon: 'activity', cat: 'Клиенты',
      metrics: () => [{ v: 7, sub: 'событий за день' }],
    }),
    projects: def({
      title: 'Объекты', icon: 'folder-kanban', cat: 'Производство',
      metrics: () => { const ps = P(); return [{ v: ps.length || 4, sub: 'объекта в работе' }, { label: 'Средняя готовность', v: (ps.length ? Math.round(ps.reduce((a, p) => a + (p.pct || 0), 0) / ps.length) : 52) + '%' }, { label: 'С отставанием', v: 1 }]; },
      chart: () => (P().length ? P().map((p) => p.pct || 0) : [94, 5, 93, 18]),
    }),
    tasks: def({
      title: 'Задачи и сроки', icon: 'clock-alert', cat: 'Производство',
      metrics: () => { const all = E(); return [{ v: all.filter((e) => e.type === 'overdue' || e.severity === 'high').length || 4, sub: 'требуют реакции', delta: { v: '+1', dir: 'up', good: false } }, { label: 'Всего', v: all.length || 9 }, { label: 'За неделю', v: 28 }]; },
      chart: () => [5, 4, 6, 3, 5, 2, 3],
    }),
    procurement: def({
      title: 'Закупки', icon: 'package', cat: 'Производство',
      metrics: () => [{ v: 3, sub: 'ожидают поставки' }, { label: 'В пути', v: 1 }, { label: 'Ближайшая', v: '9 июл' }],
    }),
    crews: def({
      title: 'Загрузка бригад', icon: 'users-round', cat: 'Команда',
      metrics: () => { const ts = T(); return [{ v: O.TEAM_LOAD_TOTAL || 71, unit: '%', sub: 'средняя загрузка' }, { label: 'Бригад', v: ts.length || 3 }, { label: 'Человек', v: (ts.reduce((a, t) => a + ((t.crew || []).length), 0)) || 24 }]; },
      chart: () => ({ donut: (T().length ? T().map((t) => t.load || 0) : [82, 64, 68]) }),
    }),
    finance: def({
      title: 'Финансы', icon: 'wallet', cat: 'Финансы',
      metrics: () => [{ v: '15.7', unit: 'млн ₽', sub: 'остаток бюджетов', delta: { v: '+8%', dir: 'up' } }, { label: 'Сметы', v: '18.4 млн ₽' }, { label: 'Освоено', v: '6.4 млн ₽' }],
      chart: () => [3.2, 4.1, 3.8, 5.2, 6.0, 6.4],
      chartPrev: () => [2.8, 3.4, 3.6, 4.4, 5.1, 5.6],
    }),
    'ai-insight': def({
      title: 'AI-ассистент', icon: 'sparkles', cat: 'Операционный центр',
      metrics: () => [{ v: (E().filter((e) => e.type === 'overdue' || e.severity === 'high').length) || 0, sub: 'сигналов от ИИ' }],
      render: ({ tier }) => {
        const ex = E();
        const risk = ex.find((e) => e.type === 'overdue' || e.severity === 'high');
        if (tier === 0) return <K.MetricStack items={[{ v: ex.length || 0, sub: 'сигналов от ИИ' }]} ch={120} />;
        const insight = 'Освоение бюджетов растёт (+8% к прошлому месяцу). '
          + (risk ? ('Риск: «' + risk.title + '»' + (risk.age ? (' — ' + risk.age) : '') + '.') : 'Критичных исключений нет.')
          + ' Загрузка бригад ' + (O.TEAM_LOAD_TOTAL || 71) + '%.';
        return (
          <div className="of-ai">
            <div className="of-ai-badge"><Ic n="sparkles" s={13} />Инсайт</div>
            <div className="of-ai-text">{insight}</div>
            <div className="of-ai-chips">
              {['Где риски?', 'Прогноз выручки', 'Кого разгрузить?'].map((q) => (
                <button key={q} type="button" className="of-ai-chip" onClick={() => { try { if (window.sendPrompt) window.sendPrompt(q); } catch (e) {} }}>{q}</button>
              ))}
            </div>
          </div>
        );
      },
    }),
    /* зональные виджеты — на L встраивают полный контент раздела ОЦ */
    'zone-overview': def({
      title: 'Обзор', icon: 'layout-dashboard', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: P().length || 4, sub: 'объекта в работе' }, { label: 'Исключений', v: E().length || 0 }],
      render: ({ tier, ctx }) => {
        if (tier >= 2 && window.Overview) {
          return (
            <div className="of-zone-embed">
              <window.Overview period={ctx.period} prevLabel={ctx.prevLabel} go={ctx.go} openProject={ctx.openProject} openClient={ctx.openClient} />
            </div>
          );
        }
        return <K.MetricStack items={[{ v: P().length || 4, sub: 'объекта' }, { label: 'Исключений', v: E().length || 0 }]} ch={tier === 0 ? 120 : 170} />;
      },
      full: ({ ctx }) => window.Overview
        ? <div className="of-zone-embed"><window.Overview period={ctx.period} prevLabel={ctx.prevLabel} go={ctx.go} openProject={ctx.openProject} openClient={ctx.openClient} /></div>
        : null,
    }),
    'zone-feed': def({
      title: 'Лента', icon: 'activity', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: 7, sub: 'событий за день' }],
      render: ({ tier, ctx }) => {
        if (tier >= 2 && window.FeedZone) {
          return <div className="of-zone-embed"><window.FeedZone openProject={ctx.openProject} /></div>;
        }
        return <div className="of-scroll"><K.List rows={FEED.slice(0, tier === 0 ? 3 : 6)} /></div>;
      },
      full: ({ ctx }) => window.FeedZone ? <div className="of-zone-embed"><window.FeedZone openProject={ctx.openProject} /></div> : null,
    }),
    'zone-clients': def({
      title: 'Клиенты', icon: 'users', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: C().length || 12, sub: 'активных клиентов' }],
      render: ({ tier, ctx }) => {
        if (tier >= 2 && window.ClientsZone) {
          return (
            <div className="of-zone-embed">
              <window.ClientsZone subTab={ctx.clientsSubTab} setSubTab={ctx.setClientsSubTab} openProject={ctx.openProject} onCardOpen={ctx.onClientCardOpen} />
            </div>
          );
        }
        return <K.MetricStack items={[{ v: C().length || 12, sub: 'клиентов' }, { label: 'Новых', v: 4 }]} ch={tier === 0 ? 120 : 170} />;
      },
      full: ({ ctx }) => window.ClientsZone
        ? <div className="of-zone-embed"><window.ClientsZone subTab={ctx.clientsSubTab} setSubTab={ctx.setClientsSubTab} openProject={ctx.openProject} onCardOpen={ctx.onClientCardOpen} /></div>
        : null,
    }),
    'zone-team': def({
      title: 'Команда', icon: 'users-round', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: (O.TEAM || []).length || 0, sub: 'человек в реестре' }],
      render: ({ tier, ctx }) => {
        if (tier >= 2 && window.TeamZone) return <div className="of-zone-embed"><window.TeamZone /></div>;
        return <K.MetricStack items={[{ v: (O.TEAM || []).length || 0, sub: 'в реестре' }, { label: 'Бригад', v: T().length || 3 }]} ch={tier === 0 ? 120 : 170} />;
      },
      full: () => window.TeamZone ? <div className="of-zone-embed"><window.TeamZone /></div> : null,
    }),
    'zone-analytics': def({
      title: 'Аналитика', icon: 'bar-chart-3', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: '17%', sub: 'конверсия воронки' }],
      render: ({ tier, ctx }) => {
        if (tier >= 2 && window.AnalyticsZone) {
          return <div className="of-zone-embed"><window.AnalyticsZone period={ctx.period} prevLabel={ctx.prevLabel} go={ctx.go} /></div>;
        }
        return <K.MetricStack items={[{ v: '17%', sub: 'конверсия' }, { label: 'Договоров', v: 3 }]} ch={tier === 0 ? 120 : 170} />;
      },
      full: ({ ctx }) => window.AnalyticsZone
        ? <div className="of-zone-embed"><window.AnalyticsZone period={ctx.period} prevLabel={ctx.prevLabel} go={ctx.go} /></div>
        : null,
    }),
    'zone-catalog': def({
      title: 'Каталог', icon: 'library', cat: 'Операционный центр', zone: true,
      metrics: () => [{ v: '3', sub: 'уровня иерархии' }],
      render: ({ tier, ctx }) => {
        const DS = window.DesignSystem_9e0a09 || {};
        const { Card, Button } = DS;
        const { CardHead } = window;
        if (tier >= 1 && Card && Button && CardHead) {
          return (
            <div className="of-zone-embed of-zone-embed--catalog">
              <Card variant="glass" padding="lg">
                <CardHead title="Конструктор проектов" sub="Сметная база, узлы и позиции"
                  action={<Button variant="primary" size="sm" onClick={() => ctx.onCatalogOpen && ctx.onCatalogOpen()}>Открыть конструктор</Button>} />
                <div className="ops-catalog-entry">
                  <div className="ops-catalog-entry-copy">
                    <div className="ops-catalog-entry-title">Полный экран вместо вложенного длинного скролла</div>
                    <div className="ops-catalog-entry-sub">Поиск по базе, иерархия категорий, узлы и сметные позиции открываются в широком pop-up.</div>
                  </div>
                  <div className="ops-catalog-entry-kpis">
                    <div><b>Категории</b><span>иерархия работ</span></div>
                    <div><b>Узлы</b><span>сборка смет</span></div>
                    <div><b>Позиции</b><span>детальные строки</span></div>
                  </div>
                </div>
              </Card>
            </div>
          );
        }
        return <K.MetricStack items={[{ v: 'Конструктор', sub: 'сметная база' }]} ch={120} />;
      },
      full: ({ ctx }) => {
        const body = WIDGETS['zone-catalog'].render({ tier: 2, ctx });
        return (
          <React.Fragment>
            {body}
            {ctx.onCatalogOpen && (
              <div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
                <button className="of-btn of-btn--primary" type="button" onClick={() => ctx.onCatalogOpen()}><Ic n="external-link" s={14} />Открыть в полный экран</button>
              </div>
            )}
          </React.Fragment>
        );
      },
    }),
    'zone-site': def({
      title: 'Сайт', icon: 'globe', cat: 'Операционный центр', zone: true,
      metrics: () => {
        const m = (O.SITE && O.SITE.metrics) || {};
        return [{ v: m.visits7d || 0, sub: 'визитов за 7 дн' }, { label: 'Лиды', v: m.leadsWeek || 0 }];
      },
      render: ({ tier }) => {
        if (tier >= 2 && window.SiteZone) {
          return <div className="of-zone-embed"><window.SiteZone /></div>;
        }
        const m = (O.SITE && O.SITE.metrics) || {};
        return (
          <K.MetricStack
            items={[
              { v: m.visits7d || 0, sub: 'визитов за 7 дн' },
              { label: 'Лиды с сайта', v: m.leadsWeek || 0 },
              { label: 'CTR CTA', v: (m.ctr || 0) + '%' },
            ]}
            ch={tier === 0 ? 120 : 170}
          />
        );
      },
      full: () => (window.SiteZone ? <div className="of-zone-embed"><window.SiteZone /></div> : null),
    }),
  };
  /* Реестр открыт для внешних наборов: Field мёржит extWidgets/extPresets
     в эти указатели, поэтому один движок обслуживает и ОЦ, и роли ЛК. */
  let WIDGETS = BUILTIN_WIDGETS;
  /* Центральный реестр (KSH-356): встроенные виджеты — источник правды о их
     характеристиках/поведении. Регистрируем; внешние наборы роли добавляются в Field. */
  const REG = window.WidgetRegistry || null;
  if (REG) REG.registerAll(BUILTIN_WIDGETS);
  /* поведение виджета из реестра (drag/resize/color/expand/…); нет реестра → всё разрешено */
  const behOf = (wid) => (REG ? REG.behavior(wid) : { drag: true, resize: true, color: true, expand: true });
  const CAT_ORDER = (REG && REG.CAT_ORDER) || ['Разделы', 'Операционный центр', 'Продажи', 'Клиенты', 'Производство', 'Команда', 'Финансы'];
  const cats = () => {
    const present = [...new Set(Object.values(WIDGETS).map((w) => w.cat).filter(Boolean))];
    return CAT_ORDER.filter((c) => present.includes(c)).concat(present.filter((c) => !CAT_ORDER.includes(c)));
  };
  const catWidgets = (cat) => Object.entries(WIDGETS).filter(([, w]) => w.cat === cat);

  const FEED = [
    { t: '—', s: 'согласовал смету по этапу 2', v: '2 ч' },
    { t: '—', s: 'запросила акт скрытых работ', v: '5 ч' },
    { t: '—', s: 'новый лид из формы сайта', v: '1 дн' },
    { t: '—', s: 'перенос замера на среду', v: '1 дн' },
    { t: '—', s: 'оплата по договору получена', v: '2 дн' },
    { t: 'KSH-047', s: 'закрыт этап «черновые»', v: '3 дн' },
  ];

  /* тип графика по умолчанию (пока пользователь не выбрал в настройках) */
  const defaultViz = (wid, c) => (Array.isArray(c) && Array.isArray(c[0]) ? 'bar' : c && c.donut ? 'donut' : (wid === 'projects' || wid === 'funnel') ? 'bar' : 'line');
  const chartNums = (c) => (Array.isArray(c) && Array.isArray(c[0]) ? c.map((s) => s[1]) : (c && c.donut ? c.donut : c));
  const chartCats = (wid, c) => {
    if (Array.isArray(c) && Array.isArray(c[0])) return c.map((s) => s[0]);
    if (c && c.donut) {
      const ts = T();
      return (ts.length ? ts : [{ foreman: '—' }, { foreman: '—' }, { foreman: '—' }]).map((t) => (t.foreman || t.name || '—').split(' ')[0]);
    }
    if (wid === 'projects') return (P().length ? P() : []).map((p) => (p.code || '').slice(-3) || '·');
    return wid === 'tasks' ? DAYS : MONTHS;
  };
  /* 4 вида графика в настройках виджета */
  const VIZ_OPTS = [
    { id: 'donut', label: 'Кольцо', hint: 'Доли + среднее в центре' },
    { id: 'bar', label: 'Столбцы', hint: 'Сравнение рядом' },
    { id: 'line', label: 'Линия', hint: 'Профиль значений' },
    { id: 'hbar', label: 'Рейтинг', hint: 'Горизонтальные полосы' },
  ];
  function VizGlyph({ id }) {
    if (id === 'donut') return (
      <svg className="of-viz-glyph" viewBox="0 0 28 28" aria-hidden="true">
        <circle cx="14" cy="14" r="9" fill="none" stroke="currentColor" strokeOpacity=".22" strokeWidth="4" />
        <circle cx="14" cy="14" r="9" fill="none" stroke="currentColor" strokeWidth="4" strokeDasharray="18 40" strokeLinecap="round" transform="rotate(-90 14 14)" />
      </svg>);
    if (id === 'bar') return (
      <svg className="of-viz-glyph" viewBox="0 0 28 28" aria-hidden="true">
        <rect x="4" y="14" width="5" height="10" rx="1.5" fill="currentColor" opacity=".35" />
        <rect x="11.5" y="8" width="5" height="16" rx="1.5" fill="currentColor" opacity=".55" />
        <rect x="19" y="5" width="5" height="19" rx="1.5" fill="currentColor" />
      </svg>);
    if (id === 'line') return (
      <svg className="of-viz-glyph" viewBox="0 0 28 28" aria-hidden="true">
        <polyline points="4,20 10,14 15,17 24,7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
        <circle cx="24" cy="7" r="2.2" fill="currentColor" />
      </svg>);
    return (
      <svg className="of-viz-glyph" viewBox="0 0 28 28" aria-hidden="true">
        <rect x="4" y="6" width="16" height="3.5" rx="1.5" fill="currentColor" />
        <rect x="4" y="12.5" width="12" height="3.5" rx="1.5" fill="currentColor" opacity=".55" />
        <rect x="4" y="19" width="8" height="3.5" rx="1.5" fill="currentColor" opacity=".35" />
      </svg>);
  }

  /* график с ограниченной высотой (h). axis — ось/подписи только с M и выше.
     prev — прошлый период (только для линии, режим сравнения).
     viz — выбор пользователя из настроек (donut|bar|line|hbar). */
  function drawChart(wid, c, h, axis, prev, viz) {
    const t = viz || defaultViz(wid, c);
    const nums = chartNums(c);
    const cats = chartCats(wid, c);
    const fill = h === 'fill';
    if (t === 'donut') {
      /* ряды >5 на мини выглядят как точки — берём последние доли / топ для composition */
      const raw = nums.map((v) => Number(v) || 0).filter((v) => v > 0);
      const capped = raw.length > 5 ? raw.slice(-5) : raw;
      const center = wid === 'crews'
        ? ((O.TEAM_LOAD_TOTAL || 71) + '%')
        : (capped.length ? String(Math.round(capped.reduce((a, b) => a + b, 0) / capped.length)) : '—');
      const donutAt = (size) => {
        const showLabel = size >= 48;
        return (
          <K.DonutChart
            segments={capped.map((v) => ({ v }))}
            center={showLabel ? center : undefined}
            sub={showLabel && wid === 'crews' ? 'средняя' : undefined}
            size={Math.max(36, Math.round(size))}
          />
        );
      };
      /* fill: кольцо = min(ширина, высота) бокса — растёт вместе с карточкой.
         minH 92 — читаемый диаметр по умолчанию (иначе кольцо-«точка»).
         Низкий фикс-виджет сжимает бокс: < 40px кольцо нечитаемо → прячем, метрика
         забирает место (лучше пусто, чем «точка»). Линия/бары остаются спарклайном. */
      if (fill) return <K.FillBox minH={92} className="of-donut-fill">{(bw, bh) => (bh < 40 ? null : donutAt(Math.min(bw, bh) - 2))}</K.FillBox>;
      const size = Math.max(36, Math.min(h - (axis ? 0 : 2), axis ? Math.min(h + 24, 128) : h));
      return <div className="of-donut-wrap" style={{ height: h, maxHeight: h }}>{donutAt(size)}</div>;
    }
    if (t === 'hbar') {
      const max = Math.max.apply(null, nums.concat([1]));
      const unit = (wid === 'crews' || wid === 'projects') ? '%' : '';
      const dense = !axis || (typeof h === 'number' && h <= 72);
      /* fill: до 6 рядов, распределены по высоте; фикс: 3–4 ряда в мини-spark */
      const rowBudget = fill ? Math.min(nums.length, 6) : (dense ? Math.max(3, Math.min(4, Math.floor(h / 18))) : 99);
      return (
        <div className={'of-hbar' + (dense ? ' is-dense' : '') + (fill ? ' is-fill' : '')}
          style={fill ? undefined : { height: h, maxHeight: h }} role="img" aria-label="Рейтинг">
          {nums.slice(0, rowBudget).map((v, i) => (
            <div key={i} className="of-hbar-row">
              <span className="of-hbar-lab">{cats[i] || ('#' + (i + 1))}</span>
              <div className="of-hbar-track"><span className="of-hbar-fill" style={{ width: Math.max(4, (v / max) * 100) + '%' }} /></div>
              <b className="of-hbar-val t-num">{v}{unit}</b>
            </div>
          ))}
        </div>);
    }
    const labels = axis ? cats : undefined;
    /* line/bar: minH держит нижний порог, при fill ChartFrame сам мерит высоту */
    if (t === 'bar') return <K.BarChart data={nums} labels={labels} h={h} minH={56} showAxis={axis} />;
    return <K.LineChart data={nums} labels={labels} h={h} minH={56} showAxis={axis} prev={axis ? prev : undefined} />;
  }
  /* сравнение с прошлым периодом: дельта последнего значения */
  function periodDelta(cur, prev) {
    if (!prev || !prev.length || !cur || !cur.length) return null;
    const a = cur[cur.length - 1], b = prev[prev.length - 1];
    if (!b) return null;
    const pct = Math.round((a - b) / Math.abs(b) * 100);
    return { v: (pct >= 0 ? '+' : '') + pct + '%', dir: pct >= 0 ? 'up' : 'down', good: pct >= 0 };
  }

  /* история к графику — строки для тира L */
  function histRows(wid, c) {
    if (Array.isArray(c) && Array.isArray(c[0])) return c.map((s, i) => ({ t: s[0], s: i ? 'конв. ' + Math.round(s[1] / c[i - 1][1] * 100) + '%' : 'вход', v: s[1] }));
    if (c && c.donut) { const ts = T(); return (ts.length ? ts : [{ foreman: '—', load: 82 }, { foreman: '—', load: 64 }, { foreman: '—', load: 68 }]).map((x) => ({ t: x.foreman || x.name || '—', v: (x.load || 0) + '%', bar: x.load || 0 })); }
    if (wid === 'projects') { const ps = P(); return (ps.length ? ps : []).map((p) => ({ t: p.code, s: p.phase, v: (p.pct || 0) + '%', bar: p.pct || 0 })); }
    const labels = wid === 'tasks' ? DAYS : MONTHS;
    return chartNums(c).map((v, i) => ({ t: labels[i] || ('#' + (i + 1)), v: String(v) }));
  }

  /* тир-рендер контента виджета.
     Логика: график по высоте всегда ограничен и карточку не заполняет.
     Размер задаёт богатство: S — один атом; M — метрика + компактный график;
     L — график с историей или полная таблица/лента. */
  function renderContent(wid, tier, ctx, node) {
    const m = WIDGETS[wid]; if (!m) return null;
    if (m.render) return m.render({ tier, ctx: ctx || {}, meta: m });

    const listRows = wid === 'feed' ? FEED
      : m.rows ? m.rows().map((c) => ({ t: c.name || c.title || '—', s: c.s || c.phase || '', v: c.v || '' })) : null;
    const kind = listRows ? 'list' : m.chart ? 'chart' : 'metric';

    if (kind === 'list') {
      if (tier === 0) return <K.MetricStack items={m.metrics()} ch={120} />;
      return <div className="of-scroll"><K.List rows={listRows.slice(0, tier === 1 ? 5 : 99)} /></div>;
    }
    if (kind === 'metric') {
      return <K.MetricStack items={m.metrics()} ch={tier === 0 ? 120 : tier === 1 ? 170 : 220} />;
    }

    /* chart-виджет */
    const c = m.chart();
    const viz = (node && node.viz) || defaultViz(wid, c);
    if (tier === 0) {
      /* S: метрика + график, заполняющий остаток карточки по высоте (адаптивно).
         KSH-365: delta инлайн рядом с числом, без отдельной строки/sub — иначе клип. */
      const items = m.metrics();
      const f = items[0] || {};
      return (
        <div className="of-metricchart">
          <div className="of-metricchart-head">
            <div className="ws-metric">{f.v}{f.unit && <small>{f.unit}</small>}
              {f.delta && (
                <span className={'ws-delta ' + (f.delta.good === false ? 'is-down' : 'is-up')}>
                  <Ic n={f.delta.dir === 'down' ? 'trending-down' : 'trending-up'} s={12} />{f.delta.v}
                </span>
              )}
            </div>
            {f.sub && !f.delta && <div className="ws-sub" title={f.sub}>{f.sub}</div>}
          </div>
          {drawChart(wid, c, 'fill', false, null, viz)}
        </div>
      );
    }
    if (tier === 1) {
      /* M: метрика + график с осью, заполняющий остаток по высоте */
      return (
        <div className="of-metricchart">
          <div className="of-metricchart-head"><K.MetricStack items={[m.metrics()[0]]} ch={62} /></div>
          {drawChart(wid, c, 'fill', true, m.chartPrev && m.chartPrev(), viz)}
        </div>
      );
    }
    /* L: слева история, справа график + строка сводных чисел + сравнение периодов */
    const prev = m.chartPrev && m.chartPrev();
    const nums = chartNums(c);
    let stats = K.seriesStats ? K.seriesStats(nums) : [];
    const pd = periodDelta(nums, prev);
    if (pd) stats = [{ label: 'vs прошлый', v: pd.v }].concat(stats);
    return <K.Split left={<div className="of-scroll"><K.List rows={histRows(wid, c).slice(0, 6)} /></div>}
      right={<div style={{ width: '100%' }}>{drawChart(wid, c, 130, true, prev, viz)}{stats && stats.length ? <K.StatRow stats={stats} /> : <K.Legend items={[m.title]} />}</div>} />;
  }

  /* ── виджет ── */
  /* цвет виджета → --ws-* + remap DS-токенов (только на самом виджете) */
  function colorStyle(colorId, mode) {
    const cv = colorId ? K.cardVars(colorId, mode) : null;
    if (!cv) return null;
    const out = Object.assign({}, cv, {
      '--foreground': cv['--foreground'] || cv['--ws-fg'],
      '--text-secondary': cv['--text-secondary'] || cv['--ws-fg-sub'],
      '--text-tertiary': cv['--text-tertiary'] || cv['--ws-fg-sub'],
      '--border-subtle': cv['--border-subtle'] || cv['--ws-line'],
      '--secondary': cv['--secondary'] || cv['--ws-track'],
      '--muted': cv['--ws-track'],
      color: cv['--ws-fg'],
    });
    if (cv['--ws-card-bg']) out['--card'] = cv['--card'] || cv['--ws-card-bg'];
    return out;
  }
  /* цвет группы — только оболочка (.of-block), без каскада в виджеты */
  function shellColorStyle(colorId, mode) {
    const cv = colorId ? K.cardVars(colorId, mode) : null;
    return cv || null;
  }

  /* ColPicker / RowPicker — из window.FieldKit (см. деструктуризацию вверху модуля) */

  function Widget({ node, inBlock, parentCols, locked, ctx, onSettings, onExpand, onGrip }) {
    const meta = WIDGETS[node.wid]; if (!meta) return null;
    const total = parentCols || FIELD_COLS;
    const cols = nodeCols(node, total);
    const tier = tierOfCols(cols, total);
    const embed = meta.zone && (cols >= total || meta.fluidZone);
    const colorId = node.color || null;
    const colorMode = node.colorMode || null;
    const vars = colorStyle(colorId, colorMode);
    const fixedPx = node.fixedH ? widgetFixedPx(node.fixedH) : null;
    const sizeStyle = fixedPx ? { height: fixedPx, maxHeight: fixedPx } : null;
    const inlineTools = embed && inBlock;
    const showTools = true;
    const beh = behOf(node.wid);
    const tools = showTools ? (
      <span className={'of-w-tools' + (inlineTools ? ' of-w-tools--inline' : '')}>
        {beh.expand && <button className="of-icon" title="Развернуть" aria-label="Развернуть" onClick={() => onExpand(node)}><Ic n="maximize-2" s={14} /></button>}
        <button className="of-icon" title="Настройки" aria-label="Настройки" onClick={(e) => onSettings(e, node)}><Ic n="more-horizontal" s={14} /></button>
        {beh.drag && <button className={'of-icon of-grip of-grip--tool' + (locked ? ' is-locked' : '')} title="Перетащить" aria-label="Перетащить" onPointerDown={(e) => !locked && onGrip(e, node.id)}><Ic n="grip-vertical" s={14} /></button>}
      </span>
    ) : null;
    return (
      <section className={'of-w' + (embed && inBlock ? ' of-w--in-block' : '') + (fixedPx ? ' of-w--fixed' : '')} data-cols={cols} data-h={embed ? undefined : (node.fixedH || (!inBlock ? '1' : undefined))} data-embed={embed ? '1' : undefined} data-color={colorId || 'default'} data-color-mode={colorMode || 'fill'} style={sizeStyle ? Object.assign({}, vars || {}, sizeStyle) : (vars || undefined)}>
        {locked && <span className="of-lock"><Ic n="lock" s={11} />закреплён</span>}
        {!embed && (
          <div className="of-w-title" style={{ paddingLeft: locked ? 76 : undefined }}>
            <span className="of-w-title-t" title={meta.title}>{meta.title}</span>
          </div>
        )}
        {inlineTools ? (
          <div className="of-w-chrome">
            <span className="of-w-chrome-title">{meta.title}</span>
            {tools}
          </div>
        ) : tools}
        <div className="of-w-body">{renderContent(node.wid, tier, ctx, node)}</div>
      </section>
    );
  }

  /* контекст lk-arrange для вложенных lk-wgt внутри группы (обзор, лента и т.д.) */
  function BlockArrange({ zoneId, children }) {
    const Ctx = window.LKArrangeCtx;
    const arrange = window.useArrangeZone && zoneId ? window.useArrangeZone(zoneId) : null;
    if (!arrange || !Ctx) return children;
    return <Ctx.Provider value={arrange}>{children}</Ctx.Provider>;
  }

  /* ── блок (группа) ── */
  function Block({ node, locked, ctx, drag, collapsed, onToggleCollapse, onAddChild, onChildSettings, onChildExpand, onName, onSettings, onExpand, onGrip, onChildGrip, onGapAdd }) {
    const cols = nodeCols(node, FIELD_COLS);
    const vars = shellColorStyle(node.color, node.colorMode);
    const fixed = node.fixedH ? { maxHeight: node.fixedH * ROW + (node.fixedH - 1) * 10 } : null;
    const arrangeZone = node.arrangeKey || node.anchor || ('group-' + node.id);
    const kids = node.children || [];
    /* masonry-упаковка тела группы (KSH-367): вертикальная укладка без «воздуха».
       FieldKit — жёсткая зависимость (guard вверху модуля), хук всегда доступен. */
    const bodyRef = R.useRef(null);
    window.FieldKit.useMasonry(bodyRef, { rowUnit: MASONRY_ROW });
    const bodyItems = packWithGaps(kids, cols);
    const dropGapAt = drag && drag.overParent === node.id && drag.over === '__slot__' ? drag.insertAt : null;
    const bodyId = 'of-body-' + node.id;
    return (
      <section className={'of-block' + (collapsed ? ' is-collapsed' : '')} data-collapsed={collapsed ? '1' : undefined} data-fixed={node.fixedH ? '1' : '0'} data-color={node.color || 'default'} data-color-mode={node.colorMode || 'fill'} style={vars || undefined}>
        <div className="of-block-head">
          {onToggleCollapse && (
            <button type="button" className="of-icon of-block-fold" aria-expanded={!collapsed} aria-controls={bodyId}
              title={collapsed ? 'Развернуть зону' : 'Свернуть зону'} aria-label={collapsed ? 'Развернуть зону' : 'Свернуть зону'}
              onClick={onToggleCollapse}><Ic n={collapsed ? 'chevron-right' : 'chevron-down'} s={15} /></button>
          )}
          <input className="of-block-name" value={node.name} readOnly={locked}
            onChange={(e) => onName(node.id, e.target.value)} aria-label="Название группы" />
          {locked && <span className="of-lock" style={{ position: 'static' }}><Ic n="lock" s={11} /></span>}
          {/* ↗ · ⋯ · grip — одна полоска, без отдельной угловой ручки */}
          <span className="of-tools">
            <button type="button" className="of-icon" title="Развернуть группу" aria-label="Развернуть группу" onClick={() => onExpand(node)}><Ic n="maximize-2" s={14} /></button>
            <button type="button" className="of-icon" title="Настройки группы" aria-label="Настройки группы" onClick={(e) => onSettings(e, node)}><Ic n="more-horizontal" s={14} /></button>
            {!locked && (
              <button type="button" className={'of-icon of-grip of-grip--tool'} title="Перетащить группу" aria-label="Перетащить группу" onPointerDown={(e) => onGrip(e, node.id)}>
                <Ic n="grip-vertical" s={14} />
              </button>
            )}
          </span>
        </div>
        {!collapsed && node.anchor && ctx && ctx.head ? (
          <div className="of-block-zone-head of-block-zone-head--lede">
            {ctx.head(node.anchor)}
          </div>
        ) : null}
        {!collapsed && (
        <BlockArrange zoneId={arrangeZone}>
          <div id={bodyId} ref={bodyRef} className={'of-block-body is-masonry' + (drag && drag.armed && drag.overParent === node.id ? ' is-dragging' : '')} style={Object.assign({ gridTemplateColumns: 'repeat(' + cols + ', minmax(0, 1fr))' }, fixed || {})}>
            {bodyItems.map((item) => {
              if (item.kind === 'gap') {
                if (locked) return null;
                const hot = dropGapAt === item.at;
                return (
                  <button
                    key={'bgap-' + item.at + '-' + item.cols}
                    type="button"
                    className={'of-freeslot of-freeslot--in' + (hot ? ' is-hot' : '')}
                    data-gap-at={item.at}
                    data-parent={node.id}
                    data-gap-cols={item.cols}
                    style={{ gridColumn: 'span ' + item.cols }}
                    onClick={(e) => onGapAdd && onGapAdd(e, item.cols, item.at)}
                    aria-label={'Добавить виджет на ' + item.cols + ' колонки'}
                    title="Выбрать виджет"
                  >
                    <span className="of-freeslot-plus" aria-hidden="true"><Ic n={hot ? 'corner-down-right' : 'plus'} s={18} /></span>
                    <span className="of-freeslot-lbl">{hot ? 'Перенести сюда' : 'Добавить'}</span>
                  </button>
                );
              }
              const ch = item.node;
              const span = nodeCols(ch, cols);
              const drop = drag && drag.overParent === node.id && drag.over === ch.id ? drag.place : null;
              return (
                <div className={'of-cell' + (drop ? ' of-drop-' + drop : '') + (drag && drag.id === ch.id ? ' is-dragging' : '')}
                  key={ch.id} data-node-id={ch.id} data-parent={node.id} style={{ gridColumn: 'span ' + span, minWidth: 0 }}>
                  <Widget node={ch} inBlock parentCols={cols} locked={false} ctx={ctx}
                    onSettings={onChildSettings} onExpand={onChildExpand} onGrip={(e, id) => onChildGrip(e, node.id, id)} />
                </div>
              );
            })}
            {!locked && kids.length === 0 && (() => { const hot = dropGapAt === 0; return (
              <button className={'of-add of-add--slot of-add-inline' + (hot ? ' is-hot' : '')}
                style={{ gridColumn: '1 / -1', minHeight: 88 }}
                data-parent={node.id} data-gap-at={0} data-gap-cols={node.w || 6}
                onClick={(e) => onAddChild(e, node.id)}>
                <span><Ic n={hot ? 'corner-down-right' : 'plus'} s={20} /><small>{hot ? 'Перенести сюда' : 'Группа пустая — добавьте виджет'}</small></span>
              </button>
            ); })()}
          </div>
        </BlockArrange>
        )}
      </section>
    );
  }

  /* инвариант тем (спека 2026-07-18 §1): группа с theme принимает только
     виджеты своего cat; theme=null — принимает всё. Единственное место правила. */
  const blockAccepts = (theme, wid) => !theme || !!(WIDGETS[wid] && WIDGETS[wid].cat === theme);

  /* ── меню каталога ── */
  function Menu({ at, cols, pool, allowCat, onPick, onBlock, onClose }) {
    const ref = R.useRef(null);
    const [q, setQ] = R.useState('');
    R.useEffect(() => {
      const away = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
      const esc = (e) => { if (e.key === 'Escape') onClose(); };
      document.addEventListener('mousedown', away); document.addEventListener('keydown', esc);
      return () => { document.removeEventListener('mousedown', away); document.removeEventListener('keydown', esc); };
    }, []);
    const qq = q.trim().toLowerCase();
    /* один каталог для любого места вставки: НЕ фильтруем по ширине слота.
       pool — каталог роли (null = всё); allowCat — тема целевой группы. */
    const passSearch = (w) => !qq || (w.title || '').toLowerCase().includes(qq);
    const passPool = (wid) => !pool || pool.includes(wid);
    const pass = (wid, w) => passSearch(w) && passPool(wid) && blockAccepts(allowCat, wid);
    const ranked = Object.entries(WIDGETS)
      .filter(([wid, w]) => pass(wid, w))
      .sort((a, b) => (a[1].zone === b[1].zone ? 0 : a[1].zone ? 1 : -1));
    const quick = ranked.slice(0, 5);
    return RD.createPortal((
      <div className="of-menu" ref={ref} style={{ left: at.x, top: at.y }} role="menu">
        <div className="of-menu-head">
          <div className="of-menu-lbl">Добавить виджет</div>
          <div className="of-menu-sub">{allowCat ? 'В группу «' + allowCat + '»' : (onBlock ? 'В поле' : 'В группу')}</div>
        </div>
        <label className="of-menu-search">
          <Ic n="search" s={14} />
          <input
            value={q}
            onChange={(e) => setQ(e.target.value)}
            placeholder="Найти виджет..."
            aria-label="Поиск виджета"
          />
        </label>
        {quick.length > 0 && (
          <React.Fragment>
            <div className="of-menu-cat">Рекомендовано</div>
            {quick.map(([wid, w]) => (
              <button key={'q-' + wid} className="of-menu-i of-menu-i--quick" onClick={() => onPick(wid)}>
                <Ic n={w.icon} s={15} />{w.title}<span className="tag"><Ic n="plus" s={12} /></span>
              </button>
            ))}
            <div className="of-menu-sep" />
          </React.Fragment>
        )}
        {cats().map((cat) => {
          const items = catWidgets(cat).filter(([wid, w]) => pass(wid, w));
          if (!items.length) return null;
          return (
            <React.Fragment key={cat}>
              <div className="of-menu-cat">{cat}</div>
              {items.map(([wid, w]) => (
                <button key={wid} className="of-menu-i" onClick={() => onPick(wid)}>
                  <Ic n={w.icon} s={15} />{w.title}<span className="tag"><Ic n="plus" s={12} /></span>
                </button>
              ))}
            </React.Fragment>
          );
        })}
        {onBlock && (
          <React.Fragment>
            <div className="of-menu-sep" />
            <button className="of-menu-i accent" onClick={onBlock}><Ic n="layout-grid-add" s={15} />Пустой блок (группа)</button>
          </React.Fragment>
        )}
      </div>
    ), document.body);
  }

  /* ── popover настроек ── */
  function Pop({ at, kind, node, onPatch, onRemove, onClose }) {
    const ref = R.useRef(null);
    const [pos, setPos] = R.useState(at);
    R.useEffect(() => {
      const away = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
      const esc = (e) => { if (e.key === 'Escape') onClose(); };
      document.addEventListener('mousedown', away); document.addEventListener('keydown', esc);
      return () => { document.removeEventListener('mousedown', away); document.removeEventListener('keydown', esc); };
    }, []);
    /* репозиция под реальную высоту: измеряем после монтирования, клампим в вьюпорт.
       Раньше позиция считалась по грубой оценке popH и низ поповера уезжал за экран. */
    R.useLayoutEffect(() => {
      const el = ref.current; if (!el) return;
      const M = 8;
      const h = el.offsetHeight, w = el.offsetWidth;
      const top = Math.max(M, Math.min(at.y, window.innerHeight - h - M));
      const left = Math.max(M, Math.min(at.x, window.innerWidth - w - M));
      if (top !== pos.y || left !== pos.x) setPos({ x: left, y: top });
    }, [at.x, at.y, node.id, kind]);
    const isBlock = kind === 'block';
    const cols = nodeCols(node, FIELD_COLS);
    const meta = !isBlock && node.wid ? WIDGETS[node.wid] : null;
    const isZone = !!(meta && meta.zone);
    const isFluidZone = !!(isZone && meta.fluidZone);
    const canViz = !!(meta && meta.chart);
    const activeViz = canViz ? (node.viz || defaultViz(node.wid, meta.chart())) : null;
    return RD.createPortal((
      <div className={'of-pop' + (canViz ? ' of-pop--viz' : '')} ref={ref} style={{ left: pos.x, top: pos.y }} role="dialog" aria-label="Настройки">
        <div className="of-pop-row">
          <span className="of-pop-lbl">{isBlock ? 'Ширина группы' : 'Ширина виджета'}</span>
          {isZone && !isFluidZone ? (
            <span className="of-pop-hint">Раздел показывается на всю ширину — так виден его контент, а не только заголовок.</span>
          ) : (
            <React.Fragment>
              <ColPicker
                value={cols}
                max={FIELD_COLS}
                label={isBlock ? 'Колонки группы' : 'Колонки виджета'}
                onChange={(n) => onPatch(isBlock ? { w: n, cols: n } : { cols: n, size: undefined })}
              />
              <span className="of-pop-hint">{isFluidZone
                ? 'Группа сохраняет весь контент при любой ширине. Внутренняя сетка перестраивается автоматически.'
                : 'Сетка поля: ' + FIELD_COLS + ' колонок. Dense-укладка заполняет свободные ячейки слева.'}</span>
            </React.Fragment>
          )}
        </div>
        <div className="of-pop-row">
          <span className="of-pop-lbl">Высота</span>
          <RowPicker
            value={node.fixedH}
            max={3}
            label={isBlock ? 'Высота группы' : 'Высота виджета'}
            onChange={(n) => onPatch({ fixedH: n })}
          />
          <span className="of-pop-hint">{isBlock ? 'Авто или фикс: скролл внутри группы + «развернуть».' : '1 стр = ' + ROW + 'px. Фикс-высота → скролл внутри + «развернуть».'}</span>
        </div>
        {canViz && (
          <div className="of-pop-row">
            <span className="of-pop-lbl">Тип графика</span>
            <div className="of-viz" role="radiogroup" aria-label="Тип графика">
              {VIZ_OPTS.map((opt) => (
                <button
                  key={opt.id}
                  type="button"
                  role="radio"
                  className={'of-viz-opt' + (activeViz === opt.id ? ' is-on' : '')}
                  aria-checked={activeViz === opt.id}
                  title={opt.hint}
                  onClick={() => onPatch({ viz: opt.id })}
                >
                  <VizGlyph id={opt.id} />
                  <span className="of-viz-lab">{opt.label}</span>
                </button>
              ))}
            </div>
            <span className="of-pop-hint">{(VIZ_OPTS.find((o) => o.id === activeViz) || {}).hint || 'Выберите вид'}</span>
          </div>
        )}
        <div className="of-pop-row">
          <span className="of-pop-lbl">Цвет</span>
          <K.GroupedPalette
            value={node.color || 'default'}
            onChange={(cid) => {
              const accent = cid && ['sand', 'linen', 'mint', 'green', 'amber', 'sky'].indexOf(cid) >= 0;
              onPatch({ color: cid, colorMode: accent ? 'outline' : null });
            }}
            className="of-pal"
          />
          <span className="of-pop-hint">Контур карточки, без заливки фона.</span>
        </div>
        <div className="of-pop-row" style={{ width: '100%' }}>
          <button className="of-pop-act" onClick={() => onPatch({ locked: !node.locked })}>
            <Ic n={node.locked ? 'lock-open' : 'lock'} s={14} />{node.locked ? 'Снять замок' : 'Закрепить'}
          </button>
          <button className="of-pop-act danger" onClick={() => { onRemove(); onClose(); }}><Ic n="trash-2" s={14} />Убрать</button>
        </div>
      </div>
    ), document.body);
  }

  /* ── поп-ап ── */
  function Modal({ view, onClose, ctx }) {
    R.useEffect(() => { const k = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []);
    if (view.kind === 'widget') {
      const meta = WIDGETS[view.node.wid];
      const body = meta.full ? meta.full({ tier: 2, ctx: ctx || {} }) : (meta.render ? meta.render({ tier: 2, ctx: ctx || {} }) : renderContent(view.node.wid, 2, ctx, view.node));
      return RD.createPortal((
        <React.Fragment>
          <div className="of-modal-ov" onClick={onClose} />
          <div className="of-modal of-modal--wide" role="dialog" aria-modal="true" aria-label={meta.title}>
            <div className="of-modal-head"><span className="of-modal-ic"><Ic n={meta.icon} s={17} /></span><span className="of-modal-title">{meta.title}</span><button className="of-icon" onClick={onClose} aria-label="Закрыть"><Ic n="x" s={17} /></button></div>
            <div className="of-modal-body">{body}</div>
          </div>
        </React.Fragment>
      ), document.body);
    }
    /* блок: содержимое во всю ширину */
    const b = view.node;
    return RD.createPortal((
      <React.Fragment>
        <div className="of-modal-ov" onClick={onClose} />
        <div className="of-modal of-modal--wide" role="dialog" aria-modal="true" aria-label={b.name}>
          <div className="of-modal-head"><span className="of-modal-ic"><Ic n="layout-grid" s={17} /></span><span className="of-modal-title">{b.name}</span><button className="of-icon" onClick={onClose} aria-label="Закрыть"><Ic n="x" s={17} /></button></div>
          <div className="of-modal-body"><div className="of-modal-grid">
            {b.children.map((ch) => {
              const meta = WIDGETS[ch.wid];
              const body = meta && meta.full ? meta.full({ tier: 2, ctx: ctx || {} })
                : meta && meta.render ? meta.render({ tier: 2, ctx: ctx || {} })
                  : renderContent(ch.wid, 2, ctx, ch);
              return (
                <div key={ch.id}>
                  <div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8, color: 'var(--text-secondary)' }}>{meta && meta.title}</div>
                  <div style={{ minHeight: 160 }}>{body}</div>
                </div>
              );
            })}
          </div></div>
        </div>
      </React.Fragment>
    ), document.body);
  }

  /* ── пресеты раскладок ── */
  const W = (wid, cols, extra) => Object.assign({ id: uid(), type: 'widget', wid, cols: cols == null ? 2 : cols, fixedH: null }, extra || {});
  const G = (name, w, kids, extra) => Object.assign({ id: uid(), type: 'block', name, w, cols: w, color: null, fixedH: null, locked: false, children: kids }, extra || {});
  const BUILTIN_PRESETS = {
    '1': () => [G('Продажи', 6, [W('funnel', 2), W('leads', 2), W('clients', 2), W('finance', 2)], { color: 'sand' })],
    '2': () => [
      G('Продажи', 3, [W('funnel', 1), W('leads', 1), W('clients', 1)], { color: 'sand' }),
      G('Производство', 3, [W('projects', 1), W('tasks', 1), W('procurement', 1)], { color: 'mint' }),
    ],
    '3': () => [
      G('Продажи', 2, [W('funnel', 1), W('leads', 1)], { color: 'sand' }),
      G('Производство', 2, [W('projects', 1), W('tasks', 1)], { color: 'mint' }),
      G('Команда', 2, [W('crews', 1), W('procurement', 1)], { color: 'sky' }),
      W('finance', 4), W('clients', 2),
      Object.assign(W('feed', 6), { color: 'graphite' }),
    ],
    'ops-center': () => [
      G('Обзор', 6, [W('zone-overview', 6)], { arrangeKey: 'overview' }),
      G('Поток и метрики', 6, [
        W('ai-insight', 4, { fixedH: 1 }), W('finance', 2, { fixedH: 1 }),
        W('funnel', 2, { fixedH: 1 }), W('leads', 2, { fixedH: 1 }), W('projects', 2, { fixedH: 1 }), W('tasks', 2, { fixedH: 1 }),
        W('feed', 4, { fixedH: 1 }), W('clients', 2, { fixedH: 1 }), W('crews', 2, { fixedH: 1 }),
      ], { anchor: 'workspace' }),
      /* Аналитика — единый embed зоны (виджеты можно добавить вручную в «Поток и метрики») */
      G('Аналитика', 6, [W('zone-analytics', 6)], { anchor: 'analytics' }),
      G('Лента', 6, [W('zone-feed', 6)], { anchor: 'feed' }),
      G('Клиенты', 6, [W('zone-clients', 6)], { anchor: 'clients' }),
      G('Команда', 6, [W('zone-team', 6)], { anchor: 'team' }),
      G('Каталог', 6, [W('zone-catalog', 6)], { anchor: 'catalog' }),
      G('Сайт', 6, [W('zone-site', 6)], { anchor: 'site' }),
    ],
  };
  let PRESETS = BUILTIN_PRESETS;
  const DEMO_PRESETS = ['1', '2', '3'];

  /* ── поле ── */
  function Field({ ctx = {}, preset = '1', storeKey = STORE, barMode = 'demo', showBar = true, widgets: extWidgets, presets: extPresets, migrateStore, pool = null }) {
    /* открываем реестры под внешние наборы роли (один движок для ОЦ и ЛК).
       Один экземпляр поля на страницу, поэтому указатели можно делить. */
    WIDGETS = extWidgets ? Object.assign({}, BUILTIN_WIDGETS, extWidgets) : BUILTIN_WIDGETS;
    /* внешние виджеты роли — в общий реестр, чтобы lk-arrange/hub-wgt читали их meta/поведение */
    if (REG && extWidgets) REG.registerAll(extWidgets);
    /* портируемые контент-виджеты из реестра (напр. lk-content-widgets) — в рабочий набор,
       чтобы они были в каталоге и рендерились ops-field (значит cross-container DnD). */
    if (REG) {
      const extras = {};
      REG.list().forEach((w) => { if (!WIDGETS[w.id]) extras[w.id] = w; });
      if (Object.keys(extras).length) WIDGETS = Object.assign({}, WIDGETS, extras);
    }
    PRESETS = extPresets ? Object.assign({}, BUILTIN_PRESETS, extPresets) : BUILTIN_PRESETS;
    const presetKey = PRESETS[preset] ? preset : Object.keys(PRESETS)[0];
    const load = () => {
      try {
        const raw = JSON.parse(localStorage.getItem(storeKey)) || {};
        const next = typeof migrateStore === 'function' ? migrateStore(raw) : raw;
        if (next !== raw) localStorage.setItem(storeKey, JSON.stringify(next));
        return next;
      } catch (e) {
        return {};
      }
    };
    const [store, setStore] = R.useState(load);
    const [arr, setArr] = R.useState(store.arr || presetKey);
    const [nodes, setNodes] = R.useState(() => (store.custom && store.custom[store.arr || presetKey]) || PRESETS[store.arr || presetKey]());
    const [menu, setMenu] = R.useState(null);
    const [pop, setPop] = R.useState(null);
    const [modal, setModal] = R.useState(null);
    const [drag, setDrag] = R.useState(null);
    const saveT = R.useRef(null);
    const pendingCols = R.useRef(null);
    const pendingInsert = R.useRef(null);

    const persist = (next, a) => {
      clearTimeout(saveT.current);
      const aa = a || arr;
      saveT.current = setTimeout(() => {
        /* функционально от prev — не затираем collapsed, если он изменился в окне дебаунса */
        setStore((prev) => {
          const s = Object.assign({}, prev, { arr: aa, custom: Object.assign({}, prev.custom, { [aa]: next }) });
          try { localStorage.setItem(storeKey, JSON.stringify(s)); } catch (e) {}
          return s;
        });
      }, 400);
    };
    const commit = (next) => { setNodes(next); persist(next); };

    /* KSH-366: сворачивание блоков-зон. Ключ по anchor (стабилен при «Сброс»), иначе id. */
    const collapsed = store.collapsed || {};
    const collapsedKey = (n) => n.anchor || n.id;
    const toggleCollapse = (key) => {
      setStore((prev) => {
        const cur = Object.assign({}, prev.collapsed);
        if (cur[key]) delete cur[key]; else cur[key] = true;
        const s = Object.assign({}, prev, { collapsed: cur });
        try { localStorage.setItem(storeKey, JSON.stringify(s)); } catch (e) {}
        return s;
      });
    };

    const switchArr = (a) => {
      if (!PRESETS[a]) return;
      setArr(a);
      const next = (store.custom && store.custom[a]) || PRESETS[a]();
      setNodes(next);
      const s = Object.assign({}, store, { arr: a }); setStore(s); try { localStorage.setItem(storeKey, JSON.stringify(s)); } catch (e) {}
    };
    const reset = () => {
      const next = PRESETS[arr]();
      setNodes(next);
      setStore((prev) => {
        const custom = Object.assign({}, prev.custom); delete custom[arr];
        const s = { arr: arr, custom: custom, collapsed: {} };
        try { localStorage.setItem(storeKey, JSON.stringify(s)); } catch (e) {}
        return s;
      });
    };

    /* мутации */
    const patchNode = (id, p) => commit(nodes.map((n) => (n.id === id ? Object.assign({}, n, p) : Object.assign({}, n, { children: n.children ? n.children.map((c) => (c.id === id ? Object.assign({}, c, p) : c)) : n.children }))));
    const removeNode = (id) => commit(nodes.filter((n) => n.id !== id).map((n) => (n.children ? Object.assign({}, n, { children: n.children.filter((c) => c.id !== id) }) : n)));
    const renameBlock = (id, name) => commit(nodes.map((n) => (n.id === id ? Object.assign({}, n, { name }) : n)));

    const insertAtIndex = (list, item, at) => {
      const next = list.slice();
      const i = at == null || at < 0 || at > next.length ? next.length : at;
      next.splice(i, 0, item);
      return next;
    };

    /* zone/раздел-виджет содержателен только на всю ширину (tier L = embed);
       иначе показал бы только заголовок. Поэтому добавляем его сразу на всю сетку. */
    const isZoneWid = (wid) => !!(WIDGETS[wid] && WIDGETS[wid].zone);
    /* натуральный размер виджета (не крошечный слот, из которого открыли меню);
       широкий ужимается под контейнер, dense-укладка сама разложит. */
    const natCols = (wid, max) => (isZoneWid(wid) ? max : Math.min(2, max));
    const addWidgetToField = (wid, cols) => {
      const c = cols || natCols(wid, FIELD_COLS);
      const at = pendingInsert.current;
      pendingCols.current = null;
      pendingInsert.current = null;
      commit(insertAtIndex(nodes, W(wid, c), at));
      setMenu(null);
    };
    const addBlockToField = (cols) => {
      const c = cols || pendingCols.current || 3;
      const at = pendingInsert.current;
      pendingCols.current = null;
      pendingInsert.current = null;
      commit(insertAtIndex(nodes, G('Новая группа', c, []), at));
      setMenu(null);
    };
    const addWidgetToBlock = (blockId, wid) => {
      const at = pendingInsert.current;
      pendingCols.current = null;
      pendingInsert.current = null;
      commit(nodes.map((n) => {
        if (n.id !== blockId) return n;
        if (!blockAccepts(n.theme, wid)) return n; /* страховка: onPick мимо фильтра меню */
        const max = n.w || FIELD_COLS;
        const item = W(wid, natCols(wid, max));
        return Object.assign({}, n, { children: insertAtIndex(n.children || [], item, at) });
      }));
      setMenu(null);
    };

    /* дыры считаются packWithGaps на уровне модуля */
    const fieldItems = packWithGaps(nodes, FIELD_COLS);

    const reorderList = (list, id, overId, place, insertAt) => {
      const a = list.slice();
      const from = a.findIndex((x) => x.id === id);
      if (from < 0) return list;
      const [it] = a.splice(from, 1);
      if (typeof insertAt === 'number') {
        let to = insertAt;
        if (from < to) to -= 1;
        a.splice(Math.max(0, Math.min(to, a.length)), 0, it);
        return a;
      }
      if (overId === '__end__') { a.push(it); return a; }
      let to = a.findIndex((x) => x.id === overId);
      if (to < 0) return list;
      if (place === 'after') to += 1;
      a.splice(to, 0, it);
      return a;
    };

    /* DnD: порог 6px, before/after, слот свободного места / конец поля.
       Перенос по ВСЕЙ области: цель ищется в любом контейнере (поле или другая
       группа). Блок можно ронять только в поле (группы не вкладываются). */
    const startGrip = (e, parentId, id) => {
      e.preventDefault();
      e.stopPropagation();
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
      const originX = e.clientX;
      const originY = e.clientY;
      const srcIsBlock = parentId === 'field' && (nodes.find((n) => n.id === id) || {}).type === 'block';
      const srcNode = parentId === 'field'
        ? nodes.find((n) => n.id === id)
        : ((nodes.find((n) => n.id === parentId) || {}).children || []).find((c) => c.id === id);
      const dragWid = srcNode && srcNode.wid;
      /* темы контейнеров: блок с theme принимает только виджеты своего cat */
      const themeOf = {};
      nodes.forEach((n) => { if (n.type === 'block' && n.theme) themeOf[n.id] = n.theme; });
      const acceptsDrag = (containerId) => !dragWid || blockAccepts(themeOf[containerId], dragWid);
      let armed = false;
      let lastPointer = null;
      let scrollRaf = null;
      setDrag({ parentId, id, overParent: null, over: null, place: null, armed: false });

      /* контейнеры-цели: для блока — только поле; для виджета — контейнер, принимающий его тему */
      const targetOk = (el) => (srcIsBlock ? el.dataset.parent === 'field' : acceptsDrag(el.dataset.parent));

      const pickTarget = (ev) => {
        /* 1) прямое попадание в свободный слот */
        const gaps = [...document.querySelectorAll('[data-gap-at]')].filter(targetOk);
        for (const g of gaps) {
          const r = g.getBoundingClientRect();
          if (ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom) {
            return { overParent: g.dataset.parent, over: '__slot__', place: 'after', insertAt: Number(g.dataset.gapAt), gapCols: Number(g.dataset.gapCols) || null };
          }
        }
        const under = document.elementFromPoint(ev.clientX, ev.clientY);
        const slotHost = under && under.closest('[data-gap-at]');
        if (slotHost && targetOk(slotHost)) {
          return { overParent: slotHost.dataset.parent, over: '__slot__', place: 'after', insertAt: Number(slotHost.dataset.gapAt), gapCols: Number(slotHost.dataset.gapCols) || null };
        }

        /* 2) ближайший узел-сосед в любом допустимом контейнере */
        const els = [...document.querySelectorAll('[data-node-id]')].filter((el) => targetOk(el) && el.dataset.nodeId !== id && !el.dataset.gapAt);
        let best = null;
        let bestScore = Infinity;
        for (const el of els) {
          const r = el.getBoundingClientRect();
          const cx = r.left + r.width / 2;
          const cy = r.top + r.height / 2;
          const dx = ev.clientX - cx;
          const dy = ev.clientY - cy;
          const score = dx * dx + dy * dy;
          if (score < bestScore) {
            bestScore = score;
            const place = (Math.abs(dx) >= Math.abs(dy))
              ? (ev.clientX < cx ? 'before' : 'after')
              : (ev.clientY < cy ? 'before' : 'after');
            best = { overParent: el.dataset.parent, over: el.dataset.nodeId, place, insertAt: null };
          }
        }
        return best;
      };

      const onMove = (ev) => {
        lastPointer = { clientX: ev.clientX, clientY: ev.clientY };
        if (!armed) {
          if (Math.hypot(ev.clientX - originX, ev.clientY - originY) < 6) return;
          armed = true;
          setDrag((d) => (d ? Object.assign({}, d, { armed: true }) : d));
        }
        const best = pickTarget(ev);
        if (best) setDrag((d) => (d ? Object.assign({}, d, best) : d));
      };

      /* Длинное поле должно прокручиваться во время drag. Без этого перенос
         между зонами ниже/выше текущего viewport физически недоступен. */
      const autoScroll = () => {
        if (!lastPointer || !armed) { scrollRaf = requestAnimationFrame(autoScroll); return; }
        const edge = 76;
        const y = lastPointer.clientY;
        let step = 0;
        if (y < edge) step = -Math.ceil((edge - y) / edge * 26);
        else if (y > window.innerHeight - edge) step = Math.ceil((y - (window.innerHeight - edge)) / edge * 26);
        if (step) {
          window.scrollBy(0, step);
          const best = pickTarget(lastPointer);
          if (best) setDrag((d) => (d ? Object.assign({}, d, best) : d));
        }
        scrollRaf = requestAnimationFrame(autoScroll);
      };
      scrollRaf = requestAnimationFrame(autoScroll);

      /* извлечь узел из его контейнера (поле или дети группы) */
      const extract = (ns, container, nid) => {
        let moved = null;
        if (container === 'field') {
          const rest = ns.filter((n) => { if (n.id === nid) { moved = n; return false; } return true; });
          return { rest, moved };
        }
        const rest = ns.map((n) => {
          if (n.id !== container) return n;
          const kids = (n.children || []).filter((c) => { if (c.id === nid) { moved = c; return false; } return true; });
          return Object.assign({}, n, { children: kids });
        });
        return { rest, moved };
      };
      /* вставить узел в целевой контейнер по индексу/соседу */
      const insertInto = (list, item, d) => {
        if (d.over === '__slot__' || typeof d.insertAt === 'number') {
          const i = d.insertAt == null ? list.length : d.insertAt;
          const out = list.slice(); out.splice(Math.max(0, Math.min(i, out.length)), 0, item); return out;
        }
        let to = list.findIndex((x) => x.id === d.over);
        if (to < 0) return list.concat([item]);
        if (d.place === 'after') to += 1;
        const out = list.slice(); out.splice(to, 0, item); return out;
      };

      const onUp = () => {
        if (scrollRaf) cancelAnimationFrame(scrollRaf);
        lastPointer = null;
        setDrag((d) => {
          if (d && d.armed && d.over && d.over !== d.id && d.overParent) {
            const dst = d.overParent;
            const fitGapCols = (list, movedId, gapCols, parentTotal) => (!gapCols ? list : list.map((n) => (n.id === movedId
              ? Object.assign({}, n, { cols: Math.min(nodeCols(n, parentTotal), gapCols) }) : n)));
            if (dst === parentId) {
              /* тот же контейнер — прежний reorder (без регресса) */
              if (parentId === 'field') {
                setNodes((ns) => { let next = reorderList(ns, d.id, d.over, d.place || 'after', d.insertAt); if (d.over === '__slot__') next = fitGapCols(next, d.id, d.gapCols, FIELD_COLS); persist(next); return next; });
              } else if (d.over !== '__slot__' || typeof d.insertAt === 'number') {
                setNodes((ns) => { const next = ns.map((n) => { if (n.id !== parentId) return n; let kids = reorderList(n.children || [], d.id, d.over, d.place || 'after', d.insertAt); if (d.over === '__slot__') kids = fitGapCols(kids, d.id, d.gapCols, n.w || FIELD_COLS); return Object.assign({}, n, { children: kids }); }); persist(next); return next; });
              }
            } else {
              /* перенос между контейнерами: извлечь из src, вставить в dst с зажимом ширины */
              setNodes((ns) => {
                if (!acceptsDrag(dst)) return ns; /* страховка: тема не совпала */
                const { rest, moved } = extract(ns, parentId, d.id);
                if (!moved) return ns;
                const dstMax = dst === 'field' ? FIELD_COLS : ((rest.find((n) => n.id === dst) || {}).w || FIELD_COLS);
                const clamp = (d.over === '__slot__' && d.gapCols) ? d.gapCols : dstMax;
                /* Fluid-зоны ЛК сохраняют полный контент и могут занять узкий
                   целевой слот; legacy-зоны ОЦ по-прежнему требуют всю ширину. */
                const fluidZone = isZoneWid(moved.wid) && WIDGETS[moved.wid] && WIDGETS[moved.wid].fluidZone;
                const m = Object.assign({}, moved, {
                  cols: isZoneWid(moved.wid) && !fluidZone
                    ? dstMax
                    : Math.min(nodeCols(moved, dstMax), clamp),
                });
                let next;
                if (dst === 'field') next = insertInto(rest, m, d);
                else next = rest.map((n) => (n.id === dst ? Object.assign({}, n, { children: insertInto(n.children || [], m, d) }) : n));
                persist(next);
                return next;
              });
            }
          }
          return null;
        });
        window.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
        window.removeEventListener('pointercancel', onCancel);
      };
      const onCancel = () => {
        if (scrollRaf) cancelAnimationFrame(scrollRaf);
        lastPointer = null;
        setDrag(null);
        window.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
        window.removeEventListener('pointercancel', onCancel);
      };
      window.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
      window.addEventListener('pointercancel', onCancel);
    };

    const fieldColsOf = (n) => nodeCols(n, FIELD_COLS);

    const openSettings = (kind) => (e, node) => {
      const r = e.currentTarget.getBoundingClientRect();
      const popH = (kind === 'widget' && WIDGETS[node.wid] && WIDGETS[node.wid].chart) ? 420 : 300;
      setPop({ kind, node, at: { x: Math.min(r.left, window.innerWidth - 292), y: Math.min(r.bottom + 6, window.innerHeight - popH) } });
    };
    const openMenu = (kind, blockId, colsHint, insertAt) => (e) => {
      pendingCols.current = colsHint || null;
      pendingInsert.current = typeof insertAt === 'number' ? insertAt : null;
      const r = e.currentTarget.getBoundingClientRect();
      setMenu({ kind, blockId, cols: pendingCols.current, at: { x: Math.min(r.left, window.innerWidth - 300), y: Math.min(r.bottom + 6, window.innerHeight - 340) } });
    };

    const dropGapAt = drag && drag.armed && drag.over === '__slot__' ? drag.insertAt : null;
    const fieldDragActive = drag && drag.armed && drag.overParent === 'field';
    const canvasDragActive = drag && drag.armed;

    return (
      <div className={'of' + (canvasDragActive ? ' is-dragging' : '')}>
        {showBar && (
          <div className="of-bar" role="group" aria-label="Поле">
            {barMode === 'hub' ? (
              <React.Fragment>
                <span className="of-bar-lbl">Группы и виджеты</span>
                <span className="of-bar-hint">В пустой ячейке — «+»: откроется меню виджетов. Перетаскивание за ⋮⋮.</span>
              </React.Fragment>
            ) : (
              <React.Fragment>
                <span className="of-bar-lbl">Раскладка</span>
                <div className="of-seg" role="group" aria-label="Пример раскладки">
                  {DEMO_PRESETS.map((k) => (
                    <button key={k} aria-pressed={arr === k} onClick={() => switchArr(k)}>{k === '1' ? '1 группа' : k === '2' ? '2 группы' : '3 группы + виджеты'}</button>
                  ))}
                </div>
              </React.Fragment>
            )}
            <span className="of-spacer" />
            <button className="of-btn of-btn--primary" onClick={openMenu('field', null)}><Ic n="plus" s={14} />Добавить</button>
            <button className="of-btn" onClick={reset} title="Сбросить к примеру"><Ic n="rotate-ccw" s={13} />Сброс</button>
          </div>
        )}

        <div className={'of-field' + (fieldDragActive ? ' is-dragging' : '')}>
          {nodes.length === 0 && (
            <div className="of-empty" role="note">
              <Ic n="layout-grid" s={26} />
              <div className="of-empty-t">Поле пустое</div>
              <div className="of-empty-s">Добавьте виджет или группу через «Добавить» в панели сверху.</div>
              <button className="of-btn of-btn--primary" onClick={openMenu('field', null)}><Ic n="plus" s={14} />Добавить</button>
            </div>
          )}
          {fieldItems.map((item, idx) => {
            if (item.kind === 'gap') {
              const hot = dropGapAt === item.at && drag && drag.overParent === 'field';
              return (
                <button
                  key={'gap-' + item.at + '-' + item.cols}
                  type="button"
                  className={'of-freeslot' + (hot ? ' is-hot' : '')}
                  data-gap-at={item.at}
                  data-parent="field"
                  data-gap-cols={item.cols}
                  style={{ gridColumn: 'span ' + item.cols }}
                  onClick={openMenu('field', null, item.cols, item.at)}
                  aria-label={'Добавить виджет на ' + item.cols + ' колонки'}
                  title="Выбрать виджет"
                >
                  <span className="of-freeslot-plus" aria-hidden="true"><Ic n={hot ? 'corner-down-right' : 'plus'} s={20} /></span>
                  <span className="of-freeslot-lbl">{hot ? 'Перенести сюда' : 'Добавить'}</span>
                </button>
              );
            }
            const n = item.node;
            const cols = fieldColsOf(n);
            const drop = fieldDragActive && drag.over === n.id ? drag.place : null;
            return (
              <div className={'of-node' + (drop ? ' of-drop-' + drop : '') + (canvasDragActive && drag.id === n.id ? ' is-dragging' : '')}
                data-node-id={n.id} data-parent="field" key={n.id}
                id={n.type === 'block' && n.anchor ? 'zone-' + n.anchor : undefined}
                data-zone-anchor={n.type === 'block' && n.anchor ? n.anchor : undefined}
                data-cols={cols}
                style={{ gridColumn: 'span ' + cols }}>
                {n.type === 'block'
                  ? <div data-bid={n.id}><Block node={n} locked={n.locked} ctx={ctx} drag={drag && drag.armed ? drag : null}
                      collapsed={!!collapsed[collapsedKey(n)]} onToggleCollapse={() => toggleCollapse(collapsedKey(n))}
                      onAddChild={openMenu('block', n.id)} onName={renameBlock}
                      onChildSettings={openSettings('widget')} onChildExpand={(ch) => setModal({ kind: 'widget', node: ch })}
                      onSettings={openSettings('block')} onExpand={(b) => setModal({ kind: 'block', node: b })}
                      onGrip={(e, id) => startGrip(e, 'field', id)}
                      onChildGrip={(e, bid, id) => startGrip(e, bid, id)}
                      onGapAdd={(e, cols, at) => openMenu('block', n.id, cols, at)(e)} /></div>
                  : <Widget node={n} locked={n.locked} ctx={ctx} parentCols={FIELD_COLS}
                      onSettings={openSettings('widget')} onExpand={(w) => setModal({ kind: 'widget', node: w })}
                      onGrip={(e, id) => startGrip(e, 'field', id)} />}
              </div>
            );
          })}
        </div>

        {menu && <Menu at={menu.at} cols={menu.cols} pool={pool}
          allowCat={menu.kind === 'block' ? ((nodes.find((n) => n.id === menu.blockId) || {}).theme || null) : null}
          onPick={(wid) => (menu.kind === 'field' ? addWidgetToField(wid) : addWidgetToBlock(menu.blockId, wid))}
          onBlock={menu.kind === 'field' ? () => addBlockToField() : null}
          onClose={() => { pendingCols.current = null; pendingInsert.current = null; setMenu(null); }} />}
        {pop && <Pop at={pop.at} kind={pop.kind} node={pop.node}
          onPatch={(p) => { patchNode(pop.node.id, p); setPop((cur) => (cur ? Object.assign({}, cur, { node: Object.assign({}, cur.node, p) }) : cur)); }}
          onRemove={() => removeNode(pop.node.id)} onClose={() => setPop(null)} />}
        {modal && <Modal view={modal} onClose={() => setModal(null)} ctx={ctx} />}
      </div>
    );
  }

  Object.assign(window, { OpsField: Field });
})();
