/* Project page — case study. Sticky thin header, title block with meta grid,
 * overview, fillable gallery, and a next-project teaser.
 */

const { MediaSlot: MSlotP } = window;
const edpr = window.ed;
const {
  useState: useStatePr, useEffect: useEffectPr,
  useMemo: useMemoPr, useRef: useRefPr,
  useCallback: useCallbackPr
} = React;

/* Live, sidecar-backed value of an inline-editable text field. Returns the
 * owner's current override for `id` in `lang` (re-rendering whenever it
 * changes), or null when the owner hasn't edited that field. Used so the
 * auto-written overview can follow the Type / Year / Client the owner sets. */
function useTextOverridePr(id, lang) {
  const [v, setV] = useStatePr(null);
  useEffectPr(() => {
    let alive = true;
    const read = () => {
      const TS = window.TextStore;
      const ov = TS && TS.get ? TS.get(id, lang) : null;
      const next = ov != null && String(ov).trim() !== '' ? ov : null;
      if (alive) setV(next);
    };
    const TS = window.TextStore;
    if (TS && TS.load) TS.load().then(read);else read();
    const unsub = TS && TS.subscribe ? TS.subscribe(read) : null;
    return () => {alive = false;if (unsub) unsub();};
  }, [id, lang]);
  return v;
}

/* ── ProjectViewer ──────────────────────────────────────────────────────
 * Картотека-карусель кейсовых кадров. Слева — крупное активное изображение,
 * справа — вертикальная стек-карусель миниатюр (как на странице Works):
 * активная карточка в центре, соседние выглядывают сверху/снизу и плавно
 * уходят в фон. Колесо мыши, тачпад, стрелки ↑↓, тач-свайпы и клик по
 * соседней карточке двигают стек; новая активная сразу же становится
 * крупным планом слева. Каждая карточка — обычный MediaSlot (drag-and-drop,
 * видео, persist), так что добавление и замена контента ничем не
 * отличается от других мест на сайте.
 *
 * Поддерживаются ВСЕ ориентации:
 *   • главный кадр динамически подбирает aspect-ratio под натуральные
 *     размеры загруженного изображения — вертикальная фотография остаётся
 *     вертикальной, горизонтальная — горизонтальной; кадрирование не
 *     применяется (fit="contain").
 *   • миниатюры в стеке держат ровную портретную рамку (3/4), внутри неё
 *     изображение тоже contain, поэтому смешанная картотека из портретов
 *     и горизонталей читается аккуратно.
 */
function ProjectViewer({ projectId, projectIdx, lang, tone, count }) {
  // Слоты хранения: первая позиция наследует legacy id `-lead`, остальные —
  // `-gN`. Так уже загруженные фото в существующих проектах не теряются.
  const slotIds = useMemoPr(() => {
    const ids = [`proj-${projectId}-lead`];
    for (let i = 0; i < Math.max(0, count - 1); i++) ids.push(`proj-${projectId}-g${i}`);
    return ids;
  }, [projectId, count]);

  const N = slotIds.length;
  const [active, setActive] = useStatePr(0);
  useEffectPr(() => {setActive(0);}, [projectId]);

  // Натуральные пропорции всех слотов — читаем из стора. Каждая
  // миниатюра в картотеке и крупный план слева берут размер ровно по фото:
  // горизонтальное фото — горизонтальный кадр, вертикальное — вертикальный,
  // пустых полей не остаётся. Пустой слот получает своё fallback-соотношение.
  const [ratios, setRatios] = useStatePr({});
  useEffectPr(() => {
    let cancelled = false;
    const apply = () => {
      const fresh = {};
      if (!slotIds.length) return;
      const flush = () => {
        if (cancelled) return;
        setRatios((prev) => {
          // Сохраняем все ранее измеренные ратио, переписывая только
          // те слоты, для которых получили свежее значение (включая null).
          return { ...prev, ...fresh };
        });
      };
      slotIds.forEach((id) => {
        const entry = window.SidecarStore && window.SidecarStore.get(id);
        const url = entry && (typeof entry === 'string' ? entry : entry.u);
        // dim:<id> — крошечное значение, никогда не откладывается в отдельный
        // blob-файл. Большое фото сразу после загрузки/обновления страницы
        // какое-то время лежит в сторе как нехэшированная {__ref}-ссылка —
        // SidecarStore.get(id) намеренно возвращает null для неё, пока сам
        // <image-slot> её не догрузит. Проверяем dim: НЕЗАВИСИМО от того,
        // разрешился ли url — иначе рамка застревает на запасном соотношении
        // дольше, чем сама картинка, и кажется «растянутой».
        const dim = window.SidecarStore && window.SidecarStore.get('dim:' + id);
        if (dim && dim.w && dim.h) {
          fresh[id] = `${dim.w} / ${dim.h}`;
          flush();
          return;
        }
        if (!url) {
          fresh[id] = null;
          flush();
          return;
        }
        const img = new Image();
        img.onload = () => {
          if (img.naturalWidth && img.naturalHeight) {
            fresh[id] = `${img.naturalWidth} / ${img.naturalHeight}`;
            if (window.SidecarStore) {
              try {window.SidecarStore.set('dim:' + id, { w: img.naturalWidth, h: img.naturalHeight });} catch {}
            }
          } else {
            fresh[id] = null;
          }
          flush();
        };
        img.onerror = () => {
          fresh[id] = null;
          flush();
        };
        img.src = url;
      });
    };
    if (window.SidecarStore && window.SidecarStore.load) {
      window.SidecarStore.load().then(apply);
    } else {
      apply();
    }
    const unsub = window.SidecarStore && window.SidecarStore.subscribe ?
    window.SidecarStore.subscribe(apply) :
    null;
    return () => {cancelled = true;if (unsub) unsub();};
  }, [slotIds]);

  // Wheel/touch state — те же параметры, что и в StackCarousel.
  const railRef = useRefPr(null);
  const stageRef = useRefPr(null);
  const wheelAcc = useRefPr(0);
  const lastWheel = useRefPr(0);
  const lockedTill = useRefPr(0);

  // Размеры сцены в пикселях — нужны, чтобы расставить карточки
  // с ровными промежутками. Без этого видимый зазор между картами
  // плыл бы в зависимости от их высоты (портрет vs ландшафт).
  const [stageSize, setStageSize] = useStatePr({ w: 0, h: 0 });
  useEffectPr(() => {
    const el = stageRef.current;
    if (!el) return undefined;
    const upd = () => {
      const r = el.getBoundingClientRect();
      setStageSize({ w: r.width, h: r.height });
    };
    upd();
    const RO = window.ResizeObserver;
    if (!RO) {
      window.addEventListener('resize', upd);
      return () => window.removeEventListener('resize', upd);
    }
    const ro = new RO(upd);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const go = useCallbackPr((nextIdx) => {
    setActive((nextIdx % N + N) % N);
  }, [N]);

  // Колесо мыши / тачпад: одна порция жеста = одна карточка, с
  // рефрактором, чтобы момент тачпада не проскакивал несколько кадров.
  useEffectPr(() => {
    const el = railRef.current;
    if (!el) return undefined;
    const THRESH = 110;
    const REFRACTORY = 450;
    const GESTURE_GAP = 180;

    const onWheel = (e) => {
      e.preventDefault();
      const now = performance.now ? performance.now() : Date.now();
      if (now < lockedTill.current) {
        wheelAcc.current = 0;
        lastWheel.current = now;
        return;
      }
      if (now - lastWheel.current > GESTURE_GAP) wheelAcc.current = 0;
      lastWheel.current = now;

      const dy = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
      wheelAcc.current += dy;

      if (wheelAcc.current >= THRESH) {
        wheelAcc.current = 0;
        lockedTill.current = now + REFRACTORY;
        go(active + 1);
      } else if (wheelAcc.current <= -THRESH) {
        wheelAcc.current = 0;
        lockedTill.current = now + REFRACTORY;
        go(active - 1);
      }
    };

    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [active, go]);

  // Клавиатура — активна, когда фокус на самом рейле.
  useEffectPr(() => {
    const el = railRef.current;
    if (!el) return undefined;
    const onKey = (e) => {
      if (e.key === 'ArrowDown' || e.key === 'PageDown') {e.preventDefault();go(active + 1);} else
      if (e.key === 'ArrowUp' || e.key === 'PageUp') {e.preventDefault();go(active - 1);} else
      if (e.key === 'Home') {e.preventDefault();go(0);} else
      if (e.key === 'End') {e.preventDefault();go(N - 1);}
    };
    el.addEventListener('keydown', onKey);
    return () => el.removeEventListener('keydown', onKey);
  }, [active, N, go]);

  // Touch.
  useEffectPr(() => {
    const el = railRef.current;
    if (!el) return undefined;
    let y0 = 0,dy = 0;
    const onStart = (e) => {y0 = e.touches[0].clientY;dy = 0;};
    const onMove = (e) => {dy = e.touches[0].clientY - y0;};
    const onEnd = () => {if (dy < -45) go(active + 1);else if (dy > 45) go(active - 1);};
    el.addEventListener('touchstart', onStart, { passive: true });
    el.addEventListener('touchmove', onMove, { passive: true });
    el.addEventListener('touchend', onEnd);
    return () => {
      el.removeEventListener('touchstart', onStart);
      el.removeEventListener('touchmove', onMove);
      el.removeEventListener('touchend', onEnd);
    };
  }, [active, go]);

  // Натуральный aspect-ratio активного кадра — берём из общей карты. Крупный
  // план слева принимает эти же пропорции — без пустых полей по краям.
  const activeId = slotIds[active];
  const activeRatio = ratios[activeId] || null;
  const mainStyle = activeRatio ?
  { aspectRatio: activeRatio } :
  { aspectRatio: '16 / 10' };

  const onCardClick = (i) => {if (i !== active) go(i);};

  // Расчёт вертикальных центров: активная карточка в середине,
  // соседи отходят от неё с фиксированным видимым зазором GAP. Так
  // как каждая миниатюра имеет свои пропорции + масштаб, высоту
  // считаем индивидуально для каждой карточки. GAP — это расстояние
  // между видимыми краями двух соседних карт (px).
  const CARD_W_RATIO = 0.86;
  const GAP = 10;
  const scaleFor = (abs) =>
  abs === 0 ? 1.00 :
  abs === 1 ? 0.86 :
  abs === 2 ? 0.74 :
  abs === 3 ? 0.62 : 0.52;
  const opacityFor = (abs) =>
  abs === 0 ? 1 :
  abs === 1 ? 0.55 :
  abs === 2 ? 0.22 :
  abs === 3 ? 0.08 : 0;
  const parseRatio = (s) => {
    if (!s) return 4 / 3;
    const m = String(s).match(/([\d.]+)\s*\/\s*([\d.]+)/);
    if (!m) return 4 / 3;
    const w = Number(m[1]);const h = Number(m[2]);
    return w > 0 && h > 0 ? w / h : 4 / 3;
  };

  const centers = useMemoPr(() => {
    const arr = new Array(N).fill(0);
    if (!stageSize.h || !stageSize.w) return arr;
    const cardW = stageSize.w * CARD_W_RATIO;
    const vh = (i) => {
      const ratio = parseRatio(ratios[slotIds[i]] || '4 / 3');
      const rawH = Math.min(cardW / ratio, stageSize.h * 0.86);
      return rawH * scaleFor(Math.abs(i - active));
    };
    arr[active] = stageSize.h / 2;
    for (let i = active + 1; i < N; i++) {
      arr[i] = arr[i - 1] + vh(i - 1) / 2 + GAP + vh(i) / 2;
    }
    for (let i = active - 1; i >= 0; i--) {
      arr[i] = arr[i + 1] - vh(i + 1) / 2 - GAP - vh(i) / 2;
    }
    return arr;
  }, [active, ratios, stageSize, slotIds, N]);

  // Рендерим только ±4 кадра от активного — этого хватает для стека и
  // экономит DOM, особенно когда количество кадров вырастет.
  const visible = [];
  for (let i = 0; i < N; i++) {
    const off = i - active;
    if (Math.abs(off) <= 4) visible.push({ i, off });
  }

  return (
    <section className="proj-viewer" aria-label={lang === 'ru' ? 'Кадры проекта' : 'Project frames'}>
      {/* Два head-блока — по одной закрывающей рамке на каждую колонку. Обе
           линии border-bottom оказываются на одном уровне, и картинка слева
           стартует ровно от полосы под «INDEX». */}
      <div className="proj-viewer-head proj-viewer-head-l pf-mono">
        <span className="pf-em">{lang === 'ru' ? 'Кадр' : 'Frame'}</span>
        <span className="proj-viewer-rule" aria-hidden="true"></span>
        <span>
          <span className="pf-num">{String(active + 1).padStart(2, '0')}</span>
          <span className="pf-em"> / {String(N).padStart(2, '0')}</span>
        </span>
      </div>
      <div className="proj-viewer-head proj-viewer-head-r pf-mono">
        <span className="pf-em">{lang === 'ru' ? 'Картотека' : 'Index'}</span>
        <span className="proj-viewer-rule" aria-hidden="true"></span>
        <span>
          <span className="pf-num">{String(active + 1).padStart(2, '0')}</span>
          <span className="pf-em"> / {String(N).padStart(2, '0')}</span>
        </span>
      </div>

      <div className="proj-viewer-stage">
        <div className="proj-viewer-main" style={mainStyle} key={activeId}>
          <MSlotP
            id={activeId}
            lang={lang}
            tone={tone}
            fit="cover"
            className="proj-viewer-main-slot"
            label={`P/${projectIdx} — ${String(active + 1).padStart(2, '0')}`} />
          
        </div>
      </div>

      <aside className="proj-viewer-rail-wrap" aria-label={lang === 'ru' ? 'Картотека кадров' : 'Frame index'}>
        <div
          className="proj-rail"
          ref={railRef}
          tabIndex={0}
          role="region"
          aria-roledescription="carousel">
          
          <button
            className="proj-rail-nav proj-rail-nav-up"
            onClick={() => go(active - 1)}
            aria-label={lang === 'ru' ? 'Предыдущий кадр' : 'Previous'}>
            
            <span className="pf-mono">↑</span>
          </button>

          <div className="proj-rail-stage" ref={stageRef}>
            {visible.map(({ i, off }) => {
              const abs = Math.abs(off);
              const scale = scaleFor(abs);
              const opacity = opacityFor(abs);
              const z = 100 - abs;
              // Каждая миниатюра берёт свои пропорции; если фото ещё не
              // загружено — fallback 4/3 (горизонтальная рамка как в эскизе).
              const slotId = slotIds[i];
              const cardRatio = ratios[slotId] || '4 / 3';
              // Центры посчитаны выше — зазор между видимыми краями
              // карт идёт ровно GAP, независимо от пропорций.
              const topPx = stageSize.h ? centers[i] : (stageSize.h || 0) / 2;

              return (
                <article
                  key={slotId}
                  className="proj-rail-card"
                  data-active={abs === 0 ? '1' : '0'}
                  style={{
                    top: `${topPx}px`,
                    aspectRatio: cardRatio,
                    transform: `translate(-50%, -50%) scale(${scale})`,
                    opacity,
                    zIndex: z,
                    pointerEvents: abs > 2 ? 'none' : 'auto'
                  }}
                  onClick={() => onCardClick(i)}
                  aria-current={abs === 0 ? 'true' : undefined}
                  aria-label={`${lang === 'ru' ? 'Кадр' : 'Frame'} ${i + 1}`}>
                  
                  <span className="proj-rail-card-num pf-mono">{String(i + 1).padStart(2, '0')}</span>
                  <MSlotP
                    id={slotId}
                    lang={lang}
                    tone={tone}
                    fit="cover"
                    className="proj-rail-card-slot"
                    label={String(i + 1).padStart(2, '0')} />
                  
                </article>);

            })}
          </div>

          <button
            className="proj-rail-nav proj-rail-nav-down"
            onClick={() => go(active + 1)}
            aria-label={lang === 'ru' ? 'Следующий кадр' : 'Next'}>
            
            <span className="pf-mono">↓</span>
          </button>
        </div>

        <div className="proj-rail-hint pf-mono pf-em">
          {lang === 'ru' ? 'СКРОЛЛ · ↑ ↓ · КЛИК' : 'SCROLL · ↑ ↓ · CLICK'}
        </div>
      </aside>
    </section>);

}
window.ProjectViewer = ProjectViewer;

/* ── ProjectLightbox ────────────────────────────────────────────────────
 * Полноэкранная «картотека» по клику на любую заполненную ячейку:
 *   • размытый фон поверх страницы,
 *   • вертикальный стек медиа в центре — активный кадр крупно, соседние
 *     кадры выглядывают сверху и снизу, дальние быстро уходят в фон,
 *   • управление как в StackCarousel из Works: колесо мыши/тачпад, ↑↓,
 *     Home/End, тач-свайпы, клик по соседу центрирует его, клик по
 *     активному кадру или клик мимо стека / Esc — закрывают,
 *   • без редакторских кнопок: рендерим медиа напрямую из стора
 *     (изображение или видео), чтобы не лазить в MediaSlot-овые тулзы.
 */
function ProjectLightbox({ ids, lang, startIdx, onClose }) {
  const N = ids.length;
  const [active, setActive] = useStatePr(Math.min(Math.max(0, startIdx), Math.max(0, N - 1)));
  const rootRef = useRefPr(null);
  const wheelAcc = useRefPr(0);
  const lastWheel = useRefPr(0);
  const lockedTill = useRefPr(0);

  // Натуральные пропорции каждого кадра — чтобы соседи могли быть
  // ландшафтными или портретными, и стек правильно их укладывал.
  // ВАЖНО: видео и картинки лежат в РАЗНЫХ ключах сайдкара
  // (id для фото, vid:<id> для видео). Раньше лайтбокс читал только
  // SidecarStore.get(id) и поэтому в карусели видео не показывались
  // (рендерилась пустая карточка). Теперь сначала проверяем VideoStore,
  // и только если видео нет — берём картинку.
  const [entries, setEntries] = useStatePr({});
  useEffectPr(() => {
    const refresh = () => {
      const next = {};
      ids.forEach((id) => {
        const v = window.VideoStore && window.VideoStore.get(id);
        if (v) {next[id] = { _kind: 'video', desc: v };return;}
        const img = window.SidecarStore && window.SidecarStore.get(id);
        if (img) next[id] = { _kind: 'image', src: img };
      });
      setEntries(next);
    };
    if (window.VideoStore && window.VideoStore.load) {
      window.VideoStore.load().then(refresh);
    } else {
      refresh();
    }
    // VideoStore и SidecarStore делят один pub/sub — одной подписки хватает.
    const unsub = window.SidecarStore && window.SidecarStore.subscribe ?
    window.SidecarStore.subscribe(refresh) : null;
    return () => {if (unsub) unsub();};
  }, [ids]);

  const go = useCallbackPr((nextIdx) => {
    if (N === 0) return;
    setActive((nextIdx % N + N) % N);
  }, [N]);

  // Колёсико/тачпад — по одному шагу на жест, с рефрактором.
  useEffectPr(() => {
    const el = rootRef.current;
    if (!el) return undefined;
    const THRESH = 110,REFRACTORY = 450,GESTURE_GAP = 180;
    const onWheel = (e) => {
      e.preventDefault();
      const now = performance.now ? performance.now() : Date.now();
      if (now < lockedTill.current) {wheelAcc.current = 0;lastWheel.current = now;return;}
      if (now - lastWheel.current > GESTURE_GAP) wheelAcc.current = 0;
      lastWheel.current = now;
      const dy = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
      wheelAcc.current += dy;
      if (wheelAcc.current >= THRESH) {wheelAcc.current = 0;lockedTill.current = now + REFRACTORY;go(active + 1);} else
      if (wheelAcc.current <= -THRESH) {wheelAcc.current = 0;lockedTill.current = now + REFRACTORY;go(active - 1);}
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [active, go]);

  // Клавиатура (Esc, ↑↓, Home/End) — глобально, пока лайтбокс открыт.
  useEffectPr(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {e.preventDefault();onClose();} else
      if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === 'ArrowRight') {e.preventDefault();go(active + 1);} else
      if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'ArrowLeft') {e.preventDefault();go(active - 1);} else
      if (e.key === 'Home') {e.preventDefault();go(0);} else
      if (e.key === 'End') {e.preventDefault();go(N - 1);}
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [active, N, go, onClose]);

  // Тач.
  useEffectPr(() => {
    const el = rootRef.current;
    if (!el) return undefined;
    let y0 = 0,dy = 0;
    const onStart = (e) => {y0 = e.touches[0].clientY;dy = 0;};
    const onMove = (e) => {dy = e.touches[0].clientY - y0;};
    const onEnd = () => {if (dy < -45) go(active + 1);else if (dy > 45) go(active - 1);};
    el.addEventListener('touchstart', onStart, { passive: true });
    el.addEventListener('touchmove', onMove, { passive: true });
    el.addEventListener('touchend', onEnd);
    return () => {
      el.removeEventListener('touchstart', onStart);
      el.removeEventListener('touchmove', onMove);
      el.removeEventListener('touchend', onEnd);
    };
  }, [active, go]);

  // Лок скролла страницы под лайтбоксом.
  useEffectPr(() => {
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => {document.body.style.overflow = prevOverflow;};
  }, []);

  // Фокус для клавиатуры.
  useEffectPr(() => {
    const el = rootRef.current;
    if (el) {try {el.focus({ preventScroll: true });} catch {try {el.focus();} catch {}}}
  }, []);

  const renderMedia = (id) => {
    const entry = entries[id];
    if (!entry) return null;
    if (entry._kind === 'video') {
      const d = entry.desc;
      if (d.kind === 'embed') {
        return <iframe src={d.src} title="video" frameBorder="0" allow="autoplay; fullscreen; picture-in-picture" />;
      }
      // Для kind=file VideoStore уже отдал data: URL — конвертим в blob: URL,
      // чтобы плеер не давился base64 и стартовал почти мгновенно (та же
      // оптимизация, что и в MediaSlot).
      const src = d.kind === 'file' && typeof window.msDataUrlToBlobUrl === 'function' ?
      window.msDataUrlToBlobUrl(d.src) :
      d.src;
      return (
        <video
          src={src}
          ref={(el) => { if (el) { el.muted = true; el.defaultMuted = true; el.volume = 0; } }}
          autoPlay
          muted
          loop
          playsInline
          preload="auto"
          onLoadedMetadata={(e) => {try {e.currentTarget.muted = true;e.currentTarget.play().catch(() => {});} catch {}}}
          onCanPlay={(e) => {try {e.currentTarget.muted = true;e.currentTarget.play().catch(() => {});} catch {}}} />);


    }
    if (entry._kind === 'image') {
      const e = entry.src;
      if (typeof e === 'string') return <img src={e} alt="" draggable={false} />;
      if (e && e.u) return <img src={e.u} alt="" draggable={false} />;
    }
    return null;
  };

  const onCardClick = (i, e) => {
    e.stopPropagation();
    if (i === active) {onClose();return;}
    go(i);
  };

  return (
    <div
      className="proj-lb"
      ref={rootRef}
      tabIndex={-1}
      role="dialog"
      aria-modal="true"
      aria-label={lang === 'ru' ? 'Картотека кадров' : 'Frame index'}
      onClick={onClose}>
      
      <div className="proj-lb-stage" onClick={(e) => e.stopPropagation()}>
        {ids.map((id, i) => {
          const off = i - active;
          const abs = Math.abs(off);
          if (abs > 4) return null;
          const STEP = 68;
          const ty = off * STEP;
          const scale =
          abs === 0 ? 1.00 :
          abs === 1 ? 0.88 :
          abs === 2 ? 0.74 :
          abs === 3 ? 0.62 : 0.52;
          const opacity =
          abs === 0 ? 1 :
          abs === 1 ? 0.70 :
          abs === 2 ? 0.30 :
          abs === 3 ? 0.10 : 0;
          const z = 100 - abs;
          return (
            <div
              key={id}
              className="proj-lb-card"
              data-active={abs === 0 ? '1' : '0'}
              style={{
                transform: `translate(-50%, calc(-50% + ${ty}vh)) scale(${scale})`,
                opacity,
                zIndex: z,
                pointerEvents: abs > 2 ? 'none' : 'auto'
              }}
              onClick={(e) => onCardClick(i, e)}
              aria-current={abs === 0 ? 'true' : undefined}>
              
              {renderMedia(id)}
            </div>);

        })}
      </div>

      <button
        className="proj-lb-close pf-mono"
        onClick={(e) => {e.stopPropagation();onClose();}}
        aria-label={lang === 'ru' ? 'Закрыть' : 'Close'}>
        ×</button>
      <div className="proj-lb-count pf-mono" aria-live="polite">
        <span className="pf-num">{String(active + 1).padStart(2, '0')}</span>
        <span className="pf-em"> / {String(N).padStart(2, '0')}</span>
      </div>
      <div className="proj-lb-hint pf-mono pf-em">
        {lang === 'ru' ? 'СКРОЛЛ · ↑ ↓ · ESC' : 'SCROLL · ↑ ↓ · ESC'}
      </div>
    </div>);

}
window.ProjectLightbox = ProjectLightbox;

/* ── ProjectStage ────────────────────────────────────────────────────────
 * Новая структура внутри проекта: слева — Overview и Deliverables одной
 * колонкой, справа — мозаика из 13 кадров (один крупный сверху во всю
 * ширину, остальные парами по два). Каждая ячейка подстраивается под
 * натуральные пропорции загруженной картинки. Клик по заполненной
 * ячейке открывает лайтбокс-картотеку: размытый фон + вертикальный стек
 * медиа с тем же управлением, что и в разделе Works.
 */
function ProjectStage({ projectId, projectIdx, lang, tone, interlude, interludeAfter = 0, slotIds: slotIdsProp, slotPrefix }) {
  const slotIds = useMemoPr(() => {
    if (slotIdsProp && slotIdsProp.length) return slotIdsProp;
    const prefix = slotPrefix || `proj-${projectId}`;
    // First slot keeps the legacy `-lead` name for graphic-design projects so
    // existing uploads survive; for other sections we use a clean `-g0..gN` row.
    const ids = slotPrefix ?
    [] :
    [`${prefix}-lead`];
    // Только проект p02 («RICE») получает +10 дополнительных слотов под уже
    // загруженными — нумерация g0..gN продолжается, поэтому ранее залитые
    // картинки остаются на местах. Остальные проекты не меняются.
    const extra = !slotPrefix && projectId === 'p02' ? 7 : !slotPrefix && projectId === 'p03' ? 2 : 0;
    // Photo series (slotPrefix set) get 18 frame slots — the original 13
    // (g0..g12 → P/xx — 01..13) plus 5 more below (g13..g17 → 14..18).
    // Existing uploads keep their g-index, so nothing already loaded moves.
    // Exception: project ph4 drops frames 17 & 18 (g16, g17) → 16 slots.
    const photoCount = projectId === 'ph4' ? 16 : 18;
    const remaining = (slotPrefix ? photoCount : 12) + extra;
    for (let i = 0; i < remaining; i++) ids.push(`${prefix}-g${i}`);
    // Per-project removed frames: project ph3 drops frame 12 (g11) and frames
    // 15 & 16 (g13, g14) — the following frames (old 17 & 18 → g15, g16) flow
    // up into those slots and inherit their sizes, so the layout structure is
    // unchanged. Project ph2 drops its last frame (g17) — redundant.
    // Project ph5 drops frame g17 — redundant.
    const EXCLUDE =
    projectId === 'ph3' ? new Set([`${prefix}-g11`, `${prefix}-g13`, `${prefix}-g14`, `${prefix}-g17`]) :
    projectId === 'ph2' ? new Set([`${prefix}-g17`]) :
    projectId === 'ph5' ? new Set([`${prefix}-g17`]) :
    projectId === 'ph4' ? new Set([`${prefix}-g15`]) :
    projectId === 'ph6' ? new Set([`${prefix}-g11`, `${prefix}-g12`, `${prefix}-g13`, `${prefix}-g14`, `${prefix}-g15`, `${prefix}-g16`, `${prefix}-g17`]) :
    projectId === 'ph7' ? new Set([`${prefix}-g15`, `${prefix}-g16`]) :
    // Project p05: frames 09–13 (g7..g11) are extra and not needed for this
    // project — dropping them; later frames flow up and keep their sizes.
    !slotPrefix && projectId === 'p05' ? new Set([`${prefix}-g7`, `${prefix}-g8`, `${prefix}-g9`, `${prefix}-g10`, `${prefix}-g11`]) :
    // Project p06: frames 10–14 (g7..g11) are extra and not needed for this
    // project — dropping them.
    !slotPrefix && projectId === 'p06' ? new Set([`${prefix}-g7`, `${prefix}-g8`, `${prefix}-g9`, `${prefix}-g10`, `${prefix}-g11`]) :
    // Project p07 (displayed as project "06"): frames 14 & 15 (g10, g11) are
    // extra and not needed for this project — dropping them.
    !slotPrefix && projectId === 'p07' ? new Set([`${prefix}-g10`, `${prefix}-g11`]) :
    null;
    let out = EXCLUDE ? ids.filter((id) => !EXCLUDE.has(id)) : ids;
    // Per-project inserted frames: project ph3 gets one extra full-width
    // frame (g8b) right after frame g8 — same format as g8.
    if (projectId === 'ph3') {
      const at = out.indexOf(`${prefix}-g8`);
      if (at >= 0) out = [...out.slice(0, at + 1), `${prefix}-g8b`, ...out.slice(at + 1)];
    }
    // Project p07: two new empty frames are inserted right after frame 08
    // (g5) — same pair format as frames 07 & 08 (g4/g5). Everything that
    // followed (the g6 video pair, frames 09..13) shifts down two places to
    // 11..15; nothing is renumbered by hand, the display counter does it.
    if (projectId === 'p07') {
      const at = out.indexOf(`${prefix}-g5`);
      if (at >= 0) out = [...out.slice(0, at + 1), `${prefix}-g12`, ...out.slice(at + 1)];
    }
    return out;
  }, [projectId, slotIdsProp, slotPrefix]);

  // Слоты, которые показываются как ПАРА из двух картинок в одном ряду.
  // Ключ — существующий слот (левая картинка, контент сохраняется), значение —
  // id новой правой картинки-компаньона. Нумерация остальных кадров не меняется.
  const SPLIT = useMemoPr(() => {
    const m = { 'proj-p02-g3': 'proj-p02-g3-b' };
    // Project p03: frame g3 is shown as a PAIR of two half-width images in one
    // row — same size & format as the frames below it (g4 & g5). The existing
    // photo stays as the left half; a new empty companion slot (g3-b) appears
    // on the right to drop the second image. Nothing else moves.
    if (projectId === 'p03') {
      m['proj-p03-g3'] = 'proj-p03-g3-b';
      // Frame g6 — same split: the existing photo becomes the left half, a new
      // empty companion slot (g6-b) appears on the right for the second image,
      // matching the half-width pairs (frames 07 & 08) below it.
      m['proj-p03-g6'] = 'proj-p03-g6-b';
    }
    // Photo series «Seven Windows» (ph2): frame 12 (g11) is shown as a PAIR of
    // two half-width images — same size as frames 10 & 11 — instead of one
    // full-width frame. The existing photo stays as the left half; a new empty
    // companion slot (g11-b) appears on the right to drop the second image.
    if (slotPrefix && projectId === 'ph2') {
      m['ph-ph2-g11'] = 'ph-ph2-g11-b';
      // Frame g14 is shown as a TRIPLE of three equal-width images in one row
      // (same idea as the g11 pair). The existing photo stays as the first
      // third; two new empty companion slots (g14-b, g14-c) appear beside it.
      m['ph-ph2-g14'] = ['ph-ph2-g14-b', 'ph-ph2-g14-c'];
      // Frame g16 — same TRIPLE treatment as g14: the existing photo becomes
      // the first third, two new empty companion slots (g16-b, g16-c) appear
      // beside it for the second and third images.
      m['ph-ph2-g16'] = ['ph-ph2-g16-b', 'ph-ph2-g16-c'];
    }
    // Photo series ph3: frame g12 is shown as a PAIR of two half-width images
    // (same size as frames 11 & 12) instead of one frame. The existing photo
    // stays as the left half; a new empty companion slot (g12-b) appears on
    // the right for the second image.
    if (slotPrefix && projectId === 'ph3') {
      m['ph-ph3-g12'] = 'ph-ph3-g12-b';
    }
    // Photo series ph5: frame 14 (g13) is shown as a TRIPLE of three
    // equal-width images in one row (same idea as the g14 triple in ph2). The
    // existing photo stays as the first third; two new empty companion slots
    // (g13-b, g13-c) appear beside it for the second and third images.
    if (slotPrefix && projectId === 'ph5') {
      m['ph-ph5-g13'] = ['ph-ph5-g13-b', 'ph-ph5-g13-c'];
      // Frame g15 is shown as a PAIR of two half-width images in one row (same
      // size as frames 10 & 11). The existing photo stays as the left half; a
      // new empty companion slot (g15-b) appears on the right for the second.
      m['ph-ph5-g15'] = 'ph-ph5-g15-b';
      // Frame g16 is shown as a PAIR of two half-width images in one row (same
      // size & format as frames 18 & 19, the g15 pair). The existing photo
      // stays as the left half; a new empty companion slot (g16-b) appears on
      // the right for the second image.
      m['ph-ph5-g16'] = 'ph-ph5-g16-b';
    }
    // Photo series ph6: frame g7 is shown as a PAIR of two half-width images
    // in one row (same size as frames 6 & 7). The existing photo stays as the
    // left half; a new empty companion slot (g7-b) appears on the right for
    // the second image.
    if (slotPrefix && projectId === 'ph6') {
      m['ph-ph6-g7'] = 'ph-ph6-g7-b';
    }
    // Project p04 («Small Stage»): frame g6 is shown as a PAIR of two
    // half-width images in one row instead of one frame. The existing photo
    // stays as the left half; a new empty companion slot (g6-b) appears on
    // the right for the second image.
    if (projectId === 'p04') {
      m['proj-p04-g6'] = 'proj-p04-g6-b';
    }
    // Project p06 («Parallel»): frame g6 is shown as a PAIR of two
    // half-width images in one row instead of one full-width frame. The
    // existing photo stays as the left half; a new empty companion slot
    // (g6-b) appears on the right for the second image.
    if (projectId === 'p06') {
      m['proj-p06-g6'] = 'proj-p06-g6-b';
      // Frame g3 is shown as a PAIR of two half-width images in one row —
      // same size & format as frames 02 & 03 (the g1/g2 pair). The existing
      // photo stays as the left half; a new empty companion slot (g3-b)
      // appears on the right for the second image.
      m['proj-p06-g3'] = 'proj-p06-g3-b';
    }
    // Project p07 («Ясный Знак» — displayed as project 6): frame g3 is shown
    // as a PAIR of two half-width images in one row instead of one full-width
    // frame. The existing photo stays as the left half; a new empty
    // companion slot (g3-b) appears on the right for the second image.
    if (projectId === 'p07') {
      m['proj-p07-g3'] = 'proj-p07-g3-b';
      // Frame g6 (the video frame, currently a solo 'l' row) is shown as a
      // PAIR of two half-width images in one row instead — same size &
      // format as frames 07 & 08 (the g4/g5 pair). The existing video stays
      // as the left half; a new empty companion slot (g6-b) appears on the
      // right for the second image.
      m['proj-p07-g6'] = 'proj-p07-g6-b';
      // Two new empty frames (09 & 10) inserted right after frames 07 & 08 —
      // same half-width pair format.
      m['proj-p07-g12'] = 'proj-p07-g12-b';
    }
    return m;
  }, [projectId, slotPrefix]);
  // Для измерения пропорций / флага «заполнен» / лайтбокса компаньоны тоже
  // нужны — добавляем их сразу после родителя, но в раскладку ROW_PATTERN они
  // НЕ попадают (её обход идёт по slotIds), поэтому позиции не смещаются.
  const measureIds = useMemoPr(() => {
    const out = [];
    slotIds.forEach((id) => {
      out.push(id);
      const comp = SPLIT[id];
      if (comp) (Array.isArray(comp) ? comp : [comp]).forEach((c) => out.push(c));
    });
    return out;
  }, [slotIds, SPLIT]);

  // Натуральные aspect-ratio + флаг «есть контент» (картинка или видео).
  const [ratios, setRatios] = useStatePr({});
  const [filled, setFilled] = useStatePr({});
  // Контент, уже измеренный в этой сессии (по url-ключу) — чтобы не запускать
  // повторный декод одной и той же картинки на каждое изменение стора, и
  // однократная запись dim:<id> для «лечения» старых загрузок без размеров.
  const measuredRef = useRefPr(null);
  if (!measuredRef.current) measuredRef.current = new Map();
  const dimSavedRef = useRefPr(null);
  if (!dimSavedRef.current) dimSavedRef.current = new Set();
  const persistDim = useCallbackPr((id, w, h) => {
    if (!w || !h || !window.SidecarStore || dimSavedRef.current.has(id)) return;
    dimSavedRef.current.add(id);
    try {window.SidecarStore.set('dim:' + id, { w, h });} catch {}
  }, []);
  useEffectPr(() => {
    let cancelled = false;
    const apply = () => {
      const fresh = {};
      const fill = {};
      if (!measureIds.length) return;
      const flush = () => {
        if (cancelled) return;
        setRatios((prev) => ({ ...prev, ...fresh }));
        setFilled((prev) => ({ ...prev, ...fill }));
      };
      measureIds.forEach((id) => {
        const entry = window.SidecarStore && window.SidecarStore.get(id);
        // Видео живут в отдельном ключе vid:<id> через VideoStore — для
        // флага «слот заполнен» (и попадания в карусель лайтбокса) их
        // тоже нужно учитывать, иначе видео-only ячейка отфильтровывается
        // и не показывается в полноэкранной картотеке.
        const vidDesc = window.VideoStore && window.VideoStore.get(id);
        // Натуральные размеры (ключ dim:<id>) — всегда крошечные, поэтому
        // никогда не откладываются в отдельный blob-файл, в отличие от самой
        // картинки. Большое фото сразу после загрузки (или сразу после
        // обновления страницы) какое-то время лежит в сторе как
        // нехэшированная {__ref}-ссылка — SidecarStore.get(id) намеренно
        // возвращает null для неё (см. image-slot.js), чтобы не гонять тяжёлый
        // decode синхронно. Раньше это приводило к тому, что функция выходила
        // ДО проверки dim:<id> — рамка оставалась на запасном соотношении, а
        // сам <image-slot> (у него своя гидратация blob'а) мог отрендерить
        // фото раньше, чем рамка примет правильную форму — визуально это и
        // читалось как "растягивание". Читаем dim: и сырое присутствие ключа
        // независимо от того, разрешилась ли ссылка на картинку.
        const dim = window.SidecarStore && window.SidecarStore.get('dim:' + id);
        const rawPresent = !!(window.SidecarStore && window.SidecarStore.keys &&
          window.SidecarStore.keys().indexOf(id) >= 0);
        let url = null;
        let hasContent = false;
        if (entry) {
          if (typeof entry === 'string') {url = entry;hasContent = true;} else
          if (entry.u) {url = entry.u;hasContent = true;} else
          if (entry.kind) {hasContent = true; /* legacy inline video */}
        }
        if (vidDesc) hasContent = true;
        if (!hasContent && (dim || rawPresent)) hasContent = true; // hydrating blob, but we already know it's filled
        fill[id] = hasContent;

        if (!hasContent) {
          fresh[id] = null;
          flush();
          return;
        }

        // 1) Мгновенно: натуральные размеры, сохранённые при загрузке
        //    (ключ dim:<id>). Рамка сразу принимает реальные пропорции —
        //    никакого «прыжка» с запасного 16/9 на формат фото.
        //    ВАЖНО: dim:<id> общий для фото и видео в одном слоте. Если
        //    слот раньше держал фото (или другое видео) и его dim: успел
        //    сохраниться, а теперь в тот же слот залито видео — старое
        //    значение больше не соответствует реальным пропорциям клипа.
        //    Поэтому для видео-слота ЭТУ короткую дорожку пропускаем и
        //    всегда меряем сам <video> ниже (шаг 3) — он же перезапишет
        //    dim:<id> актуальным значением.
        if (dim && dim.w && dim.h && !vidDesc) {
          fresh[id] = `${dim.w} / ${dim.h}`;
          flush();
          return;
        }

        // 2) Этот же контент уже мерили в этой сессии — не перемеряем,
        //    оставляем ранее посчитанное соотношение (prev в flush).
        const contentKey = url || (vidDesc ? 'v:' + (vidDesc.src || vidDesc.kind) : '');
        if (contentKey && measuredRef.current.get(id) === contentKey) {
          flush();
          return;
        }
        if (contentKey) measuredRef.current.set(id, contentKey);

        // 3) Старая загрузка без dim — меряем по натуральным размерам и
        //    однократно «лечим», записывая dim:<id> на будущее.
        if (url) {
          const img = new Image();
          img.onload = () => {
            if (img.naturalWidth && img.naturalHeight) {
              fresh[id] = `${img.naturalWidth} / ${img.naturalHeight}`;
              persistDim(id, img.naturalWidth, img.naturalHeight);
            } else {
              fresh[id] = null;
            }
            flush();
          };
          img.onerror = () => {fresh[id] = null;flush();};
          img.src = url;
          return;
        }
        // Видео-only слот: пропорций из изображения нет, но размер кадра
        // можно достать из самого <video> (videoWidth/videoHeight после
        // loadedmetadata). Для embed (YouTube/Vimeo) оставляем fallback.
        if (vidDesc && (vidDesc.kind === 'url' || vidDesc.kind === 'file')) {
          const v = document.createElement('video');
          v.muted = true;
          v.preload = 'metadata';
          v.playsInline = true;
          // Guards against a real latent bug: clearing v.src after a
          // successful read (below) itself fires the video's error event,
          // whose handler used to unconditionally null out the ratio it had
          // JUST computed — silently reverting a correctly-detected 16:9
          // clip back to a portrait fallback a moment later. Once metadata
          // has been read, later error events (from our own cleanup) are
          // ignored instead of clobbering the result.
          let done_ = false;
          const done = () => {
            done_ = true;
            if (v.videoWidth && v.videoHeight) {
              fresh[id] = `${v.videoWidth} / ${v.videoHeight}`;
              persistDim(id, v.videoWidth, v.videoHeight);
            } else {
              fresh[id] = null;
            }
            v.src = '';
            flush();
          };
          v.onloadedmetadata = done;
          v.onerror = () => { if (done_) return; fresh[id] = null; flush(); };
          v.src = vidDesc.kind === 'file' && typeof window.msDataUrlToBlobUrl === 'function' ?
          window.msDataUrlToBlobUrl(vidDesc.src) :
          vidDesc.src;
          return;
        }
        fresh[id] = null;
        flush();
      });
    };
    if (window.SidecarStore && window.SidecarStore.load) {
      window.SidecarStore.load().then(apply);
    } else {apply();}
    const unsub = window.SidecarStore && window.SidecarStore.subscribe ?
    window.SidecarStore.subscribe(apply) : null;
    return () => {cancelled = true;if (unsub) unsub();};
  }, [measureIds]);

  // Лайтбокс показывает только заполненные кадры — без пустых плашек.
  const filledIds = useMemoPr(
    () => measureIds.filter((id) => filled[id]),
    [measureIds, filled]
  );

  const [lbOpen, setLbOpen] = useStatePr(false);
  const [lbStart, setLbStart] = useStatePr(0);
  const openLightbox = (id) => {
    const idx = filledIds.indexOf(id);
    if (idx < 0) return;
    setLbStart(idx);
    setLbOpen(true);
  };

  // Editorial layout pattern — a vertical scroll of figures with rhythmic
  // widths and occasional pairs. Walks slotIds left-to-right through the
  // pattern; any leftover slots fall back to a centered single column.
  // Ритм рядов. Длиннее, чем нужно большинству проектов, — лишние строки
  // просто не используются; для проекта с 23 слотами (p02) они задают
  // рисунок новым кадрам внизу вместо монотонной колонки.
  // Project ph7 («Field Journal») uses a uniform rhythm: one full-width hero,
  // then every following frame is a half-width 'm' image laid out in pairs —
  // same size & format as frames 2 and 3. Other projects keep the varied
  // xl / m·m / l cadence below.
  const isAllPairs = slotPrefix && projectId === 'ph7';
  const ROW_PATTERN = isAllPairs ?
  [
  ['xl'],
  ['m', 'm'], ['m', 'm'], ['m', 'm'], ['m', 'm'], ['m', 'm'],
  ['m', 'm'], ['m', 'm'], ['m', 'm'], ['m', 'm'], ['m', 'm']] :

  [
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl'],
  ['m', 'm'],
  ['l'],
  ['xl']];

  // Manually-inserted extra frames: rendered as their own full-width row and
  // skipped by the rhythmic ROW_PATTERN walk, so neighbouring frames keep
  // their cadence. Keyed by slot id. (Project ph3 gets one extra after g8.)
  const FORCE_SOLO = useMemoPr(
    () => {
      if (slotPrefix && projectId === 'ph3') return { [`${slotPrefix}-g8b`]: 'xl' };
      // Project p03 gets two extra frames below the others, as their own solo
      // rows: g12 sized 'l' and g13 sized 'xl' — same formats as frames 14 & 15
      // (g10 & g11). They're skipped by the ROW_PATTERN walk so nothing moves.
      if (!slotPrefix && projectId === 'p03') return { 'proj-p03-g12': 'l', 'proj-p03-g13': 'xl' };
      return {};
    },
    [slotPrefix, projectId]
  );

  // Per-project width overrides: force a specific slot to a given width
  // regardless of where the ROW_PATTERN walk would place it.
  //   ph4: frame g8 shrinks from full-width 'xl' to 'l' (same size as g7).
  //   ph5: frames g7 & g8 are sized 'm' — same size as frames 6 & 7 (g5, g6).
  //        frame g11 is also sized 'm' — same size as frame 9 (g8).
  const FORCE_W = useMemoPr(
    () => {
      if (!slotPrefix) return {};
      if (projectId === 'ph4') return { [`${slotPrefix}-g8`]: 'l' };
      if (projectId === 'ph5') return { [`${slotPrefix}-g7`]: 'm', [`${slotPrefix}-g8`]: 'm', [`${slotPrefix}-g11`]: 'm' };
      if (projectId === 'ph6') return { [`${slotPrefix}-g8`]: 'l' };
      return {};
    },
    [slotPrefix, projectId]
  );

  // Slots that keep their place in the rhythm (so nothing else re-flows) but
  // are simply not rendered — the empty frame disappears with no gap left
  // behind, and the visible frame numbers close up around it.
  // Project p01: frame 05 (g3) is empty and not needed.
  const HIDDEN = useMemoPr(
    () => (!slotPrefix && projectId === 'p01' ? new Set(['proj-p01-g3']) : new Set()),
    [slotPrefix, projectId]
  );

  const rows = [];
  {
    // For photo series, only the first 13 *pattern* frames follow the
    // rhythmic ROW_PATTERN (forced-solo inserts don't count toward this);
    // the extras below are solo rows with per-frame sizes:
    //   P/xx — 14,15,17 → 'l'  (smaller, like P/xx — 12)
    //   P/xx — 16,18    → 'xl' (bigger,  like P/xx — 13)
    const EXTRA_W = { 13: 'l', 14: 'l', 15: 'xl', 16: 'l', 17: 'xl' };
    // Project ph2 only: frame g15 is shown smaller ('l' instead of 'xl'),
    // matching the size of the other small frames — nothing else moves.
    if (slotPrefix && projectId === 'ph2') EXTRA_W[15] = 'l';
    const patternMax = isAllPairs ? slotIds.length : slotPrefix ? 13 : slotIds.length;
    let patternUsed = 0; // normal slots emitted into ROW_PATTERN rows
    let extraN = 13;     // EXTRA_W cursor for tail solo rows
    let ri = 0;          // ROW_PATTERN row pointer
    let i = 0;
    while (i < slotIds.length) {
      const id = slotIds[i];
      if (FORCE_SOLO[id]) {
        rows.push([{ id, idx: i, w: FORCE_SOLO[id] }]);
        i++;
        continue;
      }
      if (patternUsed < patternMax && ri < ROW_PATTERN.length) {
        const cells = [];
        for (const w of ROW_PATTERN[ri]) {
          if (patternUsed >= patternMax || i >= slotIds.length) break;
          if (FORCE_SOLO[slotIds[i]]) break; // let outer loop emit it solo
          const sid = slotIds[i];
          if (!HIDDEN.has(sid)) cells.push({ id: sid, idx: i, w });
          i++;
          patternUsed++;
        }
        ri++;
        if (cells.length) rows.push(cells);
      } else {
        rows.push([{ id, idx: i, w: EXTRA_W[extraN] || 'l' }]);
        extraN++;
        i++;
      }
    }
  }

  // Разворачиваем split-слоты в пару: левая (родитель) + правая (компаньон),
  // обе половинной ширины 'm' — ряд читается как пара картинок.
  const displayRows = rows.map((row) => {
    const out = [];
    row.forEach((c) => {
      const comp = SPLIT[c.id];
      if (comp) {
        const comps = Array.isArray(comp) ? comp : [comp];
        out.push({ ...c, w: 'm' });
        comps.forEach((id) => out.push({ id, idx: c.idx, w: 'm', companion: true }));
      } else {
        out.push(FORCE_W[c.id] ? { ...c, w: FORCE_W[c.id] } : c);
      }
    });
    return out;
  });

  const fallbackFor = (w) =>
  w === 'xl' ? '16 / 9' :
  w === 'l' ? '4 / 3' :
  w === 'm' ? '4 / 5' : '1 / 1';

  // Sequential display numbering across ALL rendered figures (including split
  // companions), in actual display order rather than slot-array index. This
  // makes a split pair read 12, 13 (not 12, 12) and keeps every following
  // frame correctly numbered. Only the printed number changes — nothing moves.
  const frameNo = {};
  {
    let n = 0;
    displayRows.forEach((row) => row.forEach((c) => { frameNo[c.id] = ++n; }));
  }

  return (
    <>
      <div className={`proj-figs${isAllPairs ? ' proj-figs--bordered' : ''}`} aria-label={lang === 'ru' ? 'Кадры проекта' : 'Project frames'}>
        {displayRows.map((row, ri) =>
        <React.Fragment key={ri}>
            <div
            className={`proj-figs-row proj-figs-row--${row.length === 1 ? 'solo' : 'pair'}`}>
            
              {row.map(({ id, idx, w }) => {
              // Заглавный кадр (первый, idx 0) НЕ подстраивает рамку под
              // пропорции фото — рамка фиксирована (16/9), а изображение
              // кадрируется под неё (fit=cover). Так формат заглавной
              // картинки одинаков во всех проектах. Остальные кадры
              // по-прежнему берут натуральные пропорции загруженного фото.
              const ratio = idx === 0 ? '16 / 9' : (ratios[id] || fallbackFor(w));
              const isFilled = !!filled[id];
              return (
                <figure
                  key={id}
                  className={`proj-fig${isFilled ? ' is-filled' : ''}`}
                  data-w={w}
                  onClick={(e) => {
                    if (!isFilled) return;
                    if (e.target.closest && e.target.closest('.mslot-tools, .mslot-tool, .mslot-pop, button, input')) return;
                    openLightbox(id);
                  }}>
                  
                    <div className="proj-fig-media" style={{ aspectRatio: ratio }}>
                      <MSlotP
                      id={id}
                      lang={lang}
                      tone={tone}
                      fit="cover"
                      className="proj-fig-slot"
                      label={`P/${projectIdx} — ${String(frameNo[id]).padStart(2, '0')}`} />
                    
                    </div>
                    <figcaption className="proj-fig-cap pf-mono pf-em">
                      <span>P/{projectIdx} — {String(frameNo[id]).padStart(2, '0')}</span>
                      <span className="proj-fig-cap-label" {...edpr(`cap-${id}`)}></span>
                    </figcaption>
                  </figure>);

            })}
            </div>
            {interlude && ri === interludeAfter &&
          <div className="proj-figs-interlude">{interlude}</div>
          }
          </React.Fragment>
        )}
      </div>
      {lbOpen && filledIds.length > 0 &&
      <ProjectLightbox
        ids={filledIds}
        lang={lang}
        startIdx={lbStart}
        onClose={() => setLbOpen(false)} />

      }
    </>);

}
window.ProjectStage = ProjectStage;

/* One overview paragraph. Renders the auto-written / owner-edited copy, but
 * removes itself from the layout entirely once the owner blanks it out (an
 * empty or whitespace-only override) — so merging all the text into the first
 * paragraph and clearing the rest leaves no empty <p> behind. While the field
 * is being edited (it's the focused node) we never yank it, so an in-flight
 * clear-and-retype doesn't make the paragraph vanish under the caret. */
function OverviewPara({ pid, i, lang, defaultText }) {
  const id = `proj-${pid}-desc${i}`;
  const [blank, setBlank] = useStatePr(false);
  useEffectPr(() => {
    let alive = true;
    const read = () => {
      const el = document.querySelector(`[data-ed="${id}"]`);
      if (el && el === document.activeElement) return; // don't reflow mid-edit
      const TS = window.TextStore;
      const v = TS && TS.get ? TS.get(id, lang) : null;
      const isBlank = v != null && String(v).trim() === '';
      if (alive) setBlank(isBlank);
    };
    const TS = window.TextStore;
    if (TS && TS.load) TS.load().then(read); else read();
    const unsub = TS && TS.subscribe ? TS.subscribe(read) : null;
    return () => { alive = false; if (unsub) unsub(); };
  }, [id, lang]);
  if (blank) return null;
  return <p className="proj-ed-para" {...edpr(id)}>{defaultText}</p>;
}

function Project({ lang, index, onBack, onOpen, onIndex, onHome }) {
  const PF = window.PORTFOLIO;
  const { COPY, PROJECTS } = PF;
  const U = COPY.ui;
  const p = PROJECTS[index];
  const next = PROJECTS[(index + 1) % PROJECTS.length];
  const prev = PROJECTS[(index - 1 + PROJECTS.length) % PROJECTS.length];

  // The overview prose is auto-written from the project's type, client and
  // year. Read those three fields LIVE from the inline editor's store so that
  // when the owner edits the Type / Year / Client on this page, the overview
  // regenerates to match — e.g. changing the type to «Book» rewrites the lead
  // sentence, and changing the year updates the date in the prose. A manual
  // edit to the overview text itself still wins (TextEditLayer re-applies that
  // override on top), so owners can always override the generated copy.
  const typeOv = useTextOverridePr(`proj-${p.id}-type2`, lang);
  const yearOv = useTextOverridePr(`proj-${p.id}-year`, lang);
  const clientOv = useTextOverridePr(`proj-${p.id}-client`, lang);
  // Next-project teaser shows the CLIENT name of the next project, not its
  // title — read that client override live so editing the client field at
  // the top of a project's own page (proj-<id>-client) is reflected the
  // moment you land on the previous project's teaser, without a reload.
  const nextClientOv = useTextOverridePr(`proj-${next.id}-client`, lang);
  const nextClientName = nextClientOv != null ? nextClientOv : next.client[lang];
  const liveDescription = useMemoPr(() => {
    const effYear = yearOv != null ? yearOv : p.year;
    const effClient = clientOv != null ? clientOv : p.client[lang];
    if (typeOv != null && PF.descByLabel) {
      return PF.descByLabel(typeOv, effClient, lang, effYear);
    }
    if (PF.desc) return PF.desc(p.type, effClient, lang, effYear);
    return p.description[lang];
  }, [PF, p, lang, typeOv, yearOv, clientOv]);

  return (
    <div className="proj proj--edit">
      {/* Sticky header */}
      <header className="proj-bar">
        <button className="proj-bar-back" onClick={onBack}>
          <span className="proj-bar-arrow">←</span> {U.back[lang]}
        </button>
        <button className="proj-bar-mark" onClick={onHome || onBack}>
          <image-slot id="brand-logo" class="proj-bar-logo-slot" shape="rect" fit="contain" position="50% 50%"
            src="assets/eizler-logo.png" placeholder={lang === 'ru' ? 'Логотип' : 'Logo'}></image-slot>
        </button>
        <div className="proj-bar-nav pf-mono">
          <button onClick={() => onOpen((index - 1 + PROJECTS.length) % PROJECTS.length)} aria-label={U.prev[lang]}>←</button>
          <span className="proj-bar-count">{p.idx} / {String(PROJECTS.length).padStart(2, '0')}</span>
          <button onClick={() => onOpen((index + 1) % PROJECTS.length)} aria-label={U.next[lang]}>→</button>
        </div>
      </header>

      {/* Title block — original layout */}
      <section className="proj-head">
        <div className="proj-meta">
          <div className="proj-meta-cell">
            <div className="pf-mono-sm pf-em">{U.client[lang]}</div>
            <div className="proj-meta-v" {...edpr(`proj-${p.id}-client`)}>{p.client[lang]}</div>
          </div>
          <div className="proj-meta-cell">
            <div className="pf-mono-sm pf-em">{U.year[lang]}</div>
            <div className="proj-meta-v pf-num" {...edpr(`proj-${p.id}-year`)}>{p.year}</div>
          </div>
          <div className="proj-meta-cell">
            <div className="pf-mono-sm pf-em">{U.type[lang]}</div>
            <div className="proj-meta-v" {...edpr(`proj-${p.id}-type2`)}>{p.typeLabel[lang]}</div>
          </div>
          <div className="proj-meta-cell">
            <div className="pf-mono-sm pf-em">{U.roleLbl[lang]}</div>
            <div className="proj-meta-v" {...edpr(`proj-${p.id}-role`)}>{p.role[lang]}</div>
          </div>
        </div>
      </section>

      {/* Editorial vertical stack of figures — hero first, then overview+deliverables, then the rest */}
      <section className="proj-ed-stage">
        <ProjectStage
          projectId={p.id}
          projectIdx={p.idx}
          lang={lang}
          tone={p.tone}
          interludeAfter={0}
          interlude={
          <div className="proj-ed-tail">
              <div className="proj-ed-tail-l">
                <div className="pf-mono-sm pf-em proj-ed-tail-h">{U.overview[lang]}</div>
                {liveDescription.map((para, i) =>
              <OverviewPara key={i} pid={p.id} i={i} lang={lang} defaultText={para} />
              )}
              </div>
              <div className="proj-ed-tail-r">
                <div className="pf-mono-sm pf-em proj-ed-tail-h">{U.deliver[lang]}</div>
                <ul className="proj-deliver">
                  {p.deliverables[lang].map((d, i) =>
                <li key={i}>
                      <span className="pf-mono proj-deliver-n">{String(i + 1).padStart(2, '0')}</span>{' '}
                      <span {...edpr(`proj-${p.id}-del${i}`)}>{d}</span>
                    </li>
                )}
                </ul>
                {(p.id === 'p01' || p.id === 'p02') && (
                  <div className="proj-ed-tail-img" style={{ marginTop: '20px' }}>
                    <MSlotP
                      id={`proj-${p.id}-tail-img`}
                      lang={lang}
                      ratio="3/2"
                      tone={p.tone}
                      fit="cover"
                      className="proj-ed-tail-img-slot"
                      label={`P/${p.idx} — note`} />
                    <div className="proj-fig-cap pf-mono pf-em">
                      <span className="proj-fig-cap-label" {...edpr(`cap-proj-${p.id}-tail-img`)}></span>
                    </div>
                  </div>
                )}
              </div>
            </div>
          } />
        
      </section>

      {/* Outro caption: a short note rendered AFTER all photos, on every
       * project. Quiet full-width caption — no heading, no divider, small
       * grey copy that doesn't compete with the editorial typography.
       * p01 ships with seed text; all other projects get an editable
       * placeholder line the owner can fill in (persists via inline edit). */}
      {p.id !== 'p03' && p.id !== 'p04' && p.id !== 'p05' && p.id !== 'p06' && p.id !== 'p07' &&
      <section className="proj-outro">
        {(p.outro ? p.outro.body[lang] : [
          lang === 'ru'
            ? 'Подпись — добавьте короткий текст под проектом. Редактируется прямо на странице.'
            : 'Caption — add a short note about this project. Edit directly on the page.'
        ]).map((para, i) => (
          <p key={i} className="proj-outro-para" {...edpr(`proj-${p.id}-outro-${i}`)}>{para}</p>
        ))}
      </section>
      }

      {/* Next teaser */}
      <div className="proj-next" role="button" tabIndex={0}
      onClick={() => onOpen((index + 1) % PROJECTS.length)}
      onKeyDown={(e) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();onOpen((index + 1) % PROJECTS.length);}}}>
        <div className="proj-next-top pf-mono">
          <span>{U.next[lang]} →</span>
          <span className="proj-next-rule" aria-hidden="true"></span>
          <span className="pf-em">{next.idx} / {String(PROJECTS.length).padStart(2, '0')}</span>
        </div>
        <div className="proj-next-row">
          <h2 className="proj-next-title" {...edpr(`proj-${next.id}-client`)}>{nextClientName}</h2>
          <div className="proj-next-imgwrap">
            <MSlotP id={`proj-${next.id}-lead`} lang={lang} ratio="16/9" tone={next.tone}
            fit="cover" className="proj-next-img" label={`P/${next.idx}`} />
          </div>
        </div>
      </div>

      {/* Footer */}
      <footer className="proj-foot">
        <button className="proj-foot-all" onClick={onBack}>← {U.allWorks[lang]}</button>
        <div className="proj-foot-r">
          <a className="proj-foot-h" href="mailto:eizler.studio@gmail.com"><span {...edpr('contact-email')}>eizler.studio@gmail.com</span></a>
          <div className="pf-mono pf-em" {...edpr('contact-based')}>{COPY.based[lang]}</div>
        </div>
      </footer>
    </div>);

}

window.Project = Project;