// Generic, data-driven CategoryPage. Renders any of the four categories from
// window.CATEGORY_PAGES (pages-data.jsx) using one shared tabular layout — the
// architecture page's system, generalized. Shared atoms (LockIcon, SRLogo,
// CategoryIcon, A_Footer) come from direction-a.jsx via window, so this file
// must load AFTER direction-a.jsx.

const CP_RULE = 'var(--rule)', CP_INK = 'var(--ink)', CP_INK2 = 'var(--ink-2)',
      CP_INK3 = 'var(--ink-3)', CP_PAPER = 'var(--paper)', CP_PAPER2 = 'var(--paper-2)';

function statusColor(status) {
  return (window.ACTIVE_STATUSES || []).indexOf(status) !== -1
    ? 'oklch(60% 0.14 70)' : CP_INK;
}

// Display only the bare 4-digit year a project was completed/done. Any
// qualifiers ('~', 'est.', 'concept', etc.) are stripped; rows with no year
// render as a single faint placeholder dash (their state shows in Status).
function yearOf(raw) {
  const m = String(raw == null ? '' : raw).match(/\d{4}/);
  return m ? m[0] : '';
}

// Shared year-cell renderer used by both the desktop matrix and the mobile card.
function YearCell({ raw, style }) {
  const year = yearOf(raw);
  if (!year)
    return <span className="mono tnum" style={{ color: CP_INK3, opacity: 0.45, fontSize: 12, ...style }}>–</span>;
  return <span className="mono tnum" style={{ color: CP_INK3, fontSize: 12, ...style }}>{year}</span>;
}

// Sortable-column support for the matrix table. Returns a comparable value for
// a row in a given column: years parse to a number (so '2024–25' sorts by
// 2024, and undated rows like 'ongoing'/'—' fall to the end ascending);
// link cells sort by their label; everything else compares as lowercased text.
function sortValue(col, row) {
  if (col.kind === 'link') {
    const v = row[col.key];
    return ((v && v.label) || '').toLowerCase();
  }
  const raw = row[col.key];
  if (col.kind === 'tnum') {
    const m = String(raw == null ? '' : raw).match(/\d{4}/);
    return m ? parseInt(m[0], 10) : Infinity;
  }
  return String(raw == null ? '' : raw).toLowerCase();
}

// Year-sort ranking with special handling for undated rows. A dated row ranks by
// its year. An undated row normally "sinks" to the very bottom of the list in
// EITHER sort direction (sink: true). The one exception: an undated project whose
// status is 'in progress' is current work, so it ranks just above the most
// recently completed project (maxYear + 0.5) instead of sinking.
function yearRank(col, row, maxYear) {
  const m = String(row[col.key] == null ? '' : row[col.key]).match(/\d{4}/);
  if (m) return { sink: false, val: parseInt(m[0], 10) };
  if (String(row.status || '').toLowerCase() === 'in progress')
    return { sink: false, val: maxYear + 0.5 };
  return { sink: true, val: 0 };
}

// Tiny sort indicator in each column header. Faint ↕ when inactive (signals the
// column is sortable), solid ▲/▼ when it's the active sort key.
function SortCaret({ active, dir }) {
  return (
    <span className="mono" style={{ fontSize: 9, lineHeight: 1, flexShrink: 0,
      opacity: active ? 1 : 0.3, transition: 'opacity .15s' }}>
      {active ? (dir === 'desc' ? '▼' : '▲') : '↕'}
    </span>
  );
}

// Breadcrumb / nav bar shared by category + subcategory pages.
function CrumbBar({ trail, section, navigate }) {
  const Logo = window.SRLogo;
  const mobile = window.useIsMobile();
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      borderBottom: `1px solid ${CP_RULE}`, padding: mobile ? '14px 22px' : '18px 56px', fontSize: 13, gap: 12 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: mobile ? 12 : 18, minWidth: 0 }}>
        <div onClick={() => navigate({ page: 'index' })}
          style={{ cursor: 'pointer', color: CP_INK, transition: 'color .2s ease', display: 'flex', flexShrink: 0 }}
          onMouseEnter={(e) => { e.currentTarget.style.color = 'oklch(0.90 0.26 124)'; }}
          onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--ink)'; }}>
          {Logo ? <Logo height={mobile ? 22 : 26} /> : null}
        </div>
        <div className="mono" style={{ color: CP_INK3, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: mobile ? 12 : 13 }}>
          {trail.map((c, i) => (
            <React.Fragment key={i}>
              {i > 0 && <span style={{ margin: mobile ? '0 8px' : '0 12px', opacity: 0.4 }}>/</span>}
              {c.to
                ? <span onClick={() => navigate(c.to)} style={{ cursor: 'pointer' }}
                    onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--ink)'; }}
                    onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--ink-3)'; }}>{c.label}</span>
                : <span style={{ color: CP_INK }}>{c.label}</span>}
            </React.Fragment>
          ))}
        </div>
      </div>
      <div className="mono" style={{ color: CP_INK3, fontSize: 12, flexShrink: 0 }}>§ {section}</div>
    </div>
  );
}

function CP_Hero({ cat, cfg, navigate }) {
  const LockIcon = window.LockIcon;
  const mobile = window.useIsMobile();
  const unlocked = window.usePhotoGate ? window.usePhotoGate() : true;
  return (
    <div style={{ padding: mobile ? '34px 22px 28px' : '52px 56px 34px', display: 'grid',
      gridTemplateColumns: (cfg.gate && unlocked && !mobile) ? '1fr 320px' : '1fr', gap: mobile ? 22 : 56, alignItems: 'start',
      borderBottom: `1px solid ${CP_RULE}` }}>
      <div>
        <div className="mono" style={{ fontSize: mobile ? 9.5 : 11, color: CP_INK3, letterSpacing: 0.5,
          textTransform: 'uppercase', marginBottom: mobile ? 14 : 20 }}>§ {cfg.section}</div>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: mobile ? 9 : 18, marginBottom: mobile ? 14 : 16, flexWrap: 'wrap' }}>
          <div style={{ fontSize: mobile ? 'clamp(34px, 9vw, 44px)' : 84, fontWeight: 500, letterSpacing: mobile ? -1.2 : -3, lineHeight: 0.95 }}>{cat.word}</div>
          <div className="mono" style={{ fontSize: mobile ? 14 : 22, color: CP_INK3, letterSpacing: -0.3 }}>[{cfg.qual}]</div>
        </div>
        <div style={{ fontSize: mobile ? 13.5 : 16, color: CP_INK2, lineHeight: 1.55, maxWidth: 560, marginBottom: mobile ? 22 : 26, textWrap: 'pretty' }}>{cat.blurb}</div>
        {/* Subcategory chips → subcategory index pages */}
        {!cfg.hideSubs && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
          {cat.subs.map((s) => (
            <button key={s.id} className="mono"
              onClick={() => navigate({ page: 'subcategory', cat: cat.word, sub: s.id })}
              style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5, padding: mobile ? '7px 10px' : '7px 11px',
                minHeight: mobile ? 36 : 'auto', boxSizing: 'border-box',
                border: `1px solid ${CP_RULE}`, background: 'transparent', color: CP_INK,
                cursor: 'pointer', letterSpacing: 0.1, display: 'inline-flex', alignItems: 'center', gap: 6,
                transition: 'background .12s, border-color .12s, color .12s' }}
              onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--ink)'; e.currentTarget.style.color = 'var(--paper)'; e.currentTarget.style.borderColor = 'var(--ink)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--ink)'; e.currentTarget.style.borderColor = 'var(--rule)'; }}>
              {s.locked && LockIcon && <span style={{ display: 'inline-flex' }}><LockIcon size={10} /></span>}
              <span style={{ fontFamily: 'var(--font-primary)', fontSize: mobile ? 12.5 : 13 }}>{s.label}</span>
              <span style={{ opacity: 0.5 }}>· {s.meta}</span>
            </button>
          ))}
        </div>
        )}
      </div>
      {/* No public gate UI. The unlock lives only at the hidden access slug.
         Once unlocked, a quiet indicator lets the owner re-lock this device. */}
      {cfg.gate && unlocked && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8 }}>
          <div className="mono" style={{ fontSize: 13, padding: '14px 16px',
            border: `1px solid ${CP_RULE}`, background: 'transparent', color: CP_INK,
            display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
              <span aria-hidden="true" style={{ color: CP_INK2 }}>✓</span>
              <span>{cfg.gate.unlockedLabel}</span>
            </span>
            <button onClick={() => window.PhotoGate && window.PhotoGate.lock()} className="mono"
              style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: 0.3, cursor: 'pointer',
                border: 'none', background: 'transparent', color: CP_INK3, padding: 0, textDecoration: 'underline',
                textUnderlineOffset: 3 }}
              onMouseEnter={(e) => { e.currentTarget.style.color = CP_INK; }}
              onMouseLeave={(e) => { e.currentTarget.style.color = CP_INK3; }}>
              re-lock
            </button>
          </div>
          <div className="mono" style={{ fontSize: 11, color: CP_INK3, lineHeight: 1.55, padding: '0 2px' }}>
            {cfg.gate.unlockedSub}
          </div>
        </div>
      )}
    </div>
  );
}

function CP_Cell({ col, row, open }) {
  const v = row[col.key];
  if (col.kind === 'tnum')
    return <YearCell raw={v} />;
  if (col.kind === 'title')
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontWeight: 500 }}>
        <span className="mono" style={{ fontSize: 11, color: CP_INK3, width: 9, flexShrink: 0,
          display: 'inline-block', transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
          transition: 'transform .25s cubic-bezier(.33,1,.68,1)' }}>›</span>
        {v}
      </div>
    );
  if (col.kind === 'mono')
    return <div className="mono" style={{ fontSize: 12, color: CP_INK2, letterSpacing: 0.1 }}>{v}</div>;
  if (col.kind === 'link')
    return v ? (
      <a href={v.url} target="_blank" rel="noopener" onClick={(e) => e.stopPropagation()}
        className="mono" style={{ fontSize: 12, color: CP_INK, textDecoration: 'none',
          textAlign: col.align === 'right' ? 'right' : 'left', display: 'flex',
          justifyContent: col.align === 'right' ? 'flex-end' : 'flex-start', alignItems: 'center', gap: 6 }}
        onMouseEnter={(e) => { e.currentTarget.style.textDecoration = 'underline'; }}
        onMouseLeave={(e) => { e.currentTarget.style.textDecoration = 'none'; }}>
        {v.label} <span style={{ opacity: 0.6 }}>↗</span>
      </a>
    ) : <span />;
  if (col.kind === 'status')
    return (
      <div className="mono" style={{ color: CP_INK2, fontSize: 12,
        textAlign: col.align === 'right' ? 'right' : 'left', display: 'inline-flex',
        alignItems: 'center', gap: 6, justifyContent: col.align === 'right' ? 'flex-end' : 'flex-start' }}>
        <span style={{ width: 7, height: 7, background: statusColor(v) }} />
        {v}
      </div>
    );
  return <div style={{ color: CP_INK2 }}>{v}</div>;
}

function CP_ImageTile({ f, images, index, mobile }) {
  const [h, setH] = React.useState(false);
  const open = () => { if (window.openImageLightbox) window.openImageLightbox(images, index); };
  return (
    <div style={{ width: mobile ? 'auto' : 196, flexShrink: 0 }}>
      <div onClick={open} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)}
        style={{ position: 'relative', overflow: 'hidden', aspectRatio: f.aspect || '4 / 3',
          border: `1px solid ${h ? CP_INK : CP_RULE}`, cursor: 'zoom-in',
          transition: 'border-color .15s' }}>
        {window.MotionMedia && <window.MotionMedia media={f} active={h} />}
        <div className="mono" style={{ position: 'absolute', top: 6, right: 6,
          width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center',
          background: CP_INK, color: CP_PAPER, fontSize: 12, lineHeight: 1,
          opacity: h ? 1 : 0, transform: h ? 'scale(1)' : 'scale(.8)', transition: 'opacity .15s, transform .15s' }}>⤢</div>
      </div>
      <div className="mono" style={{ fontSize: 10, color: CP_INK2, marginTop: 6, letterSpacing: 0.2 }}>{f.title}</div>
    </div>
  );
}

function CP_Detail({ cfg, row, open }) {
  const facts = cfg.facts(row).filter(([, val]) => val);
  const imgs = cfg.images(row);
  const mobile = window.useIsMobile();
  const unlocked = window.usePhotoGate ? window.usePhotoGate() : true;
  const gated = window.PhotoGate ? window.PhotoGate.isGated(row) : false;
  const locked = gated && !unlocked;
  // Only real stills/clips (and motion slots) show in the strip. A project with
  // imagery still to come shows a ComingSoon panel in the image column instead
  // of striped placeholders — so v1.0 reads as intentionally in-progress.
  // While locked, the photo column is omitted entirely — no strip, no count, no
  // placeholder — so a gated project is indistinguishable from one with no photos.
  const realImgs = imgs.filter((f) => f.src || f.video || f.motion);
  const showImages = !locked && realImgs.length > 0;
  const showSoon = !locked && realImgs.length === 0 && imgs.length > 0;
  const showRight = showImages || showSoon;
  return (
    <div style={{ display: 'grid', gridTemplateRows: open ? '1fr' : '0fr',
      transition: 'grid-template-rows .5s cubic-bezier(.33,1,.68,1)' }}>
      <div style={{ overflow: 'hidden' }}>
        <div style={{ padding: mobile ? '4px 22px 30px' : '8px 56px 44px', display: 'grid',
          gridTemplateColumns: mobile ? '1fr' : (showRight ? '320px 1fr' : '1fr'),
          gap: mobile ? 28 : 56, alignItems: 'start',
          opacity: open ? 1 : 0, transform: open ? 'translateY(0)' : 'translateY(8px)',
          transition: 'opacity .4s ease .1s, transform .5s cubic-bezier(.33,1,.68,1) .05s' }}>
          <div>
            <div className="mono" style={{ fontSize: 11, color: CP_INK3, letterSpacing: 0.5,
              textTransform: 'uppercase', marginBottom: 12 }}>Brief</div>
            <div style={{ fontSize: 14, color: CP_INK2, lineHeight: 1.6, marginBottom: 24, textWrap: 'pretty' }}>{cfg.brief(row)}</div>
            <div style={{ display: 'flex', flexDirection: 'column', borderTop: `1px solid ${CP_RULE}` }}>
              {facts.map(([k, val]) => (
                <div key={k} className="mono" style={{ display: 'flex', justifyContent: 'space-between',
                  gap: 16, fontSize: 12, padding: '9px 0', borderBottom: `1px solid ${CP_RULE}` }}>
                  <span style={{ color: CP_INK3, letterSpacing: 0.4, textTransform: 'uppercase', fontSize: 11 }}>{k}</span>
                  <span style={{ color: CP_INK, textAlign: 'right' }}>{val}</span>
                </div>
              ))}
            </div>
            {row.related && (
              <button className="mono" onClick={() => window.siteNavigate && window.siteNavigate(row.related.to)}
                style={{ marginTop: 20, font: 'inherit', fontFamily: 'var(--font-mono)', fontSize: 11,
                  background: 'transparent', border: `1px solid ${CP_RULE}`, color: CP_INK2, padding: '7px 11px',
                  letterSpacing: 0.4, textTransform: 'uppercase', cursor: 'pointer', transition: 'border-color .15s, color .15s' }}
                onMouseEnter={(e) => { e.currentTarget.style.borderColor = CP_INK; e.currentTarget.style.color = CP_INK; }}
                onMouseLeave={(e) => { e.currentTarget.style.borderColor = CP_RULE; e.currentTarget.style.color = CP_INK2; }}>
                made with {row.related.label} →
              </button>
            )}
          </div>
          {showImages && (
            <div>
              <div className="mono" style={{ fontSize: 11, color: CP_INK3, letterSpacing: 0.5,
                textTransform: 'uppercase', marginBottom: 12 }}>Selected images · {realImgs.length}</div>
              <div style={mobile
                ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }
                : { display: 'flex', gap: 14, flexWrap: 'wrap' }}>
                {realImgs.map((f, i) => (
                  <CP_ImageTile key={i} f={f} images={realImgs} index={i} mobile={mobile} />
                ))}
              </div>
            </div>
          )}
          {showSoon && (
            <div>
              <div className="mono" style={{ fontSize: 11, color: CP_INK3, letterSpacing: 0.5,
                textTransform: 'uppercase', marginBottom: 12 }}>Imagery</div>
              <div style={{ position: 'relative', minHeight: mobile ? 180 : 220,
                border: `1px solid ${CP_RULE}` }}>
                {window.ComingSoon && <window.ComingSoon sub="Project imagery coming soon" />}
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function CP_Row({ cfg, row, last, open, onToggle, rootRef }) {
  const [h, setH] = React.useState(false);
  const mobile = window.useIsMobile();

  if (mobile) {
    // Phone: the multi-column table can't fit, so each row becomes a stacked
    // card — chevron + title + year on top, then the remaining columns as a
    // compact wrapped meta line (reusing CP_Cell so status dots / links etc.
    // render exactly as on desktop). Tap to expand the case-study detail.
    const titleCol = cfg.columns.find((c) => c.kind === 'title');
    const yearCol = cfg.columns.find((c) => c.kind === 'tnum');
    const restCols = cfg.columns.filter((c) => c !== titleCol && c !== yearCol);
    return (
      <div ref={rootRef} style={{ borderBottom: last && !open ? 'none' : `1px solid ${CP_RULE}`,
        background: open ? CP_PAPER2 : 'transparent', transition: 'background .18s' }}>
        <div onClick={onToggle} style={{ padding: '20px 22px', cursor: 'pointer' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 500, fontSize: 15, minWidth: 0 }}>
              <span className="mono" style={{ fontSize: 11, color: CP_INK3, width: 9, flexShrink: 0,
                display: 'inline-block', transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
                transition: 'transform .25s cubic-bezier(.33,1,.68,1)' }}>›</span>
              <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{row[titleCol.key]}</span>
            </div>
            {yearCol && <YearCell raw={row[yearCol.key]} style={{ flexShrink: 0 }} />}
          </div>
          <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '6px 16px', marginTop: 10, paddingLeft: 19 }}>
            {restCols.map((col) => <CP_Cell key={col.key} col={{ ...col, align: 'left' }} row={row} open={open} />)}
          </div>
        </div>
        <CP_Detail cfg={cfg} row={row} open={open} />
      </div>
    );
  }

  return (
    <div ref={rootRef} style={{ borderBottom: last && !open ? 'none' : `1px solid ${CP_RULE}`,
      background: open ? CP_PAPER2 : 'transparent', transition: 'background .18s' }}>
      <div onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)} onClick={onToggle}
        style={{ display: 'grid', gridTemplateColumns: cfg.grid, gap: 16, padding: '18px 56px',
          alignItems: 'center', fontSize: 14, background: h && !open ? CP_PAPER2 : 'transparent',
          cursor: 'pointer', transition: 'background .12s' }}>
        {cfg.columns.map((col) => <CP_Cell key={col.key} col={col} row={row} open={open} />)}
      </div>
      <CP_Detail cfg={cfg} row={row} open={open} />
    </div>
  );
}

// Index ⇄ gallery view switch for pages that opt in via cfg.gallery (architecture).
// Left: a live count of rows. Right: a segmented control matching the mono /
// sharp-cornered system vocabulary.
function CP_ViewToggle({ view, setView, count }) {
  const mobile = window.useIsMobile();
  const opts = [
    { id: 'index', glyph: '\u2630', label: 'index' },
    { id: 'gallery', glyph: '\u25A6', label: 'gallery' },
  ];
  return (
    <div className="mono" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      padding: mobile ? '12px 22px' : '12px 56px', borderBottom: `1px solid ${CP_RULE}`, gap: 16 }}>
      <span style={{ fontSize: 11, color: CP_INK3, letterSpacing: 0.5, textTransform: 'uppercase' }}>
        {String(count).padStart(2, '0')} projects
      </span>
      <div style={{ display: 'flex', border: `1px solid ${CP_RULE}` }}>
        {opts.map((o, i) => {
          const on = view === o.id;
          return (
            <button key={o.id} onClick={() => setView(o.id)} className="mono"
              style={{ fontFamily: 'var(--font-mono)', fontSize: 12, padding: '7px 14px', minHeight: 34,
                boxSizing: 'border-box', cursor: 'pointer', border: 'none',
                borderLeft: i > 0 ? `1px solid ${CP_RULE}` : 'none',
                background: on ? CP_INK : 'transparent', color: on ? CP_PAPER : CP_INK,
                display: 'inline-flex', alignItems: 'center', gap: 7, letterSpacing: 0.1,
                transition: 'background .12s, color .12s' }}
              onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = CP_PAPER2; }}
              onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = 'transparent'; }}>
              <span style={{ fontSize: 11, opacity: 0.85 }}>{o.glyph}</span>{o.label}
            </button>
          );
        })}
      </div>
    </div>
  );
}

function CategoryPage({ catWord, navigate, openTarget }) {
  const cat = window.CATEGORIES.find((c) => c.word === catWord);
  const cfg = window.CATEGORY_PAGES[catWord];
  const [openTitle, setOpenTitle] = React.useState(openTarget || null);
  const [sort, setSort] = React.useState({ key: null, dir: 'asc' });
  // Gallery-capable pages remember the chosen view across reloads — but if we
  // arrived with a specific project to open (openTarget), force the index view
  // so its detail row actually renders.
  const [view, setView] = React.useState(() => {
    if (!(cfg && cfg.gallery)) return 'index';
    if (openTarget) return 'index';
    try { return localStorage.getItem('cp-view-' + catWord) || 'index'; } catch (e) { return 'index'; }
  });
  React.useEffect(() => {
    if (!(cfg && cfg.gallery)) return;
    try { localStorage.setItem('cp-view-' + catWord, view); } catch (e) {}
  }, [view, cfg, catWord]);
  const rowRefs = React.useRef({});
  const pendingScroll = React.useRef(null);
  const Footer = window.A_Footer;
  const Card = window.SP_Card;
  const isMobile = window.useIsMobile();
  const galleryOn = !!(cfg && cfg.gallery) && view === 'gallery';

  // Open a project from a gallery card: flip to the index view, expand that
  // row, and scroll it into view once the table has rendered.
  const openFromGallery = React.useCallback((title) => {
    pendingScroll.current = title;
    setOpenTitle(title);
    setView('index');
  }, []);

  // Click a header to sort by that column. First click = ascending; clicking
  // the active column again flips direction. Authored order is restored by
  // never sorting until a key is set.
  const onSort = React.useCallback((key) => {
    setSort((s) => (s.key === key
      ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' }
      : { key, dir: 'asc' }));
  }, []);

  const sortedRows = React.useMemo(() => {
    if (!cfg) return [];
    if (!sort.key) return cfg.rows;
    const col = cfg.columns.find((c) => c.key === sort.key);
    if (!col) return cfg.rows;
    const dir = sort.dir === 'desc' ? -1 : 1;
    // Year column: undated rows sink to the bottom in BOTH directions, except
    // 'in progress' ones which rank just above the most recent completed year.
    if (col.kind === 'tnum') {
      const maxYear = cfg.rows.reduce((mx, r) => {
        const m = String(r[col.key] == null ? '' : r[col.key]).match(/\d{4}/);
        return m ? Math.max(mx, parseInt(m[0], 10)) : mx;
      }, 0);
      return [...cfg.rows].sort((a, b) => {
        const ra = yearRank(col, a, maxYear), rb = yearRank(col, b, maxYear);
        if (ra.sink && rb.sink) return 0;
        if (ra.sink) return 1;   // a always to the bottom
        if (rb.sink) return -1;  // b always to the bottom
        if (ra.val < rb.val) return -1 * dir;
        if (ra.val > rb.val) return 1 * dir;
        return 0;
      });
    }
    // Copy before sorting — cfg.rows may be a shared array (ARCH_PROJECTS).
    return [...cfg.rows].sort((a, b) => {
      const va = sortValue(col, a), vb = sortValue(col, b);
      if (va < vb) return -1 * dir;
      if (va > vb) return 1 * dir;
      return 0;
    });
  }, [cfg, sort]);

  // When arrived here by opening a project card, scroll the now-expanded case
  // study row into view. Deferred past App's per-route scrollTo(0,0) reset.
  React.useEffect(() => {
    if (!openTarget) return;
    const el = rowRefs.current[openTarget];
    if (!el) return;
    const id = setTimeout(() => {
      const top = el.getBoundingClientRect().top + window.scrollY - 12;
      window.scrollTo(0, top);
    }, 140);
    return () => clearTimeout(id);
  }, [openTarget]);

  // After a gallery card flips the page to the index view, scroll the now-open
  // project row into view.
  React.useEffect(() => {
    if (view !== 'index' || !pendingScroll.current) return;
    const title = pendingScroll.current;
    const id = setTimeout(() => {
      const el = rowRefs.current[title];
      if (el) window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - 12);
      pendingScroll.current = null;
    }, 170);
    return () => clearTimeout(id);
  }, [view]);

  if (!cat || !cfg) return null;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
      <CrumbBar section={cfg.section} navigate={navigate}
        trail={[{ label: 'index', to: { page: 'index' } }, { label: cat.word }]} />
      <CP_Hero cat={cat} cfg={cfg} navigate={navigate} />
      {cfg.gallery && <CP_ViewToggle view={view} setView={setView} count={sortedRows.length} />}
      {!galleryOn && !isMobile && (
        <div className="mono" style={{ display: 'grid', gridTemplateColumns: cfg.grid, gap: 16,
          padding: '14px 56px', fontSize: 11, color: CP_INK3, letterSpacing: 0.5, textTransform: 'uppercase',
          background: CP_PAPER2, borderBottom: `1px solid ${CP_RULE}` }}>
          {cfg.columns.map((col) => {
            const active = sort.key === col.key;
            const right = col.align === 'right';
            return (
              <div key={col.key} onClick={() => onSort(col.key)}
                style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', userSelect: 'none',
                  justifyContent: right ? 'flex-end' : 'flex-start',
                  color: active ? CP_INK : CP_INK3, transition: 'color .15s' }}
                onMouseEnter={(e) => { if (!active) e.currentTarget.style.color = CP_INK2; }}
                onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = CP_INK3; }}>
                {right && <SortCaret active={active} dir={sort.dir} />}
                <span>{col.label}</span>
                {!right && <SortCaret active={active} dir={sort.dir} />}
              </div>
            );
          })}
        </div>
      )}
      <div style={{ flex: 1 }}>
        {galleryOn ? (
          <div style={{ padding: isMobile ? '26px 22px 48px' : '36px 56px 60px' }}>
            <div style={{ display: 'grid',
              gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(280px, 1fr))',
              gap: isMobile ? 14 : 18 }}>
              {sortedRows.map((row) => (
                Card ? <Card key={row.title} catWord={catWord} row={row}
                  onOpen={() => openFromGallery(row.title)} /> : null
              ))}
            </div>
          </div>
        ) : (
          sortedRows.map((row, i) => (
            <CP_Row key={row.title} cfg={cfg} row={row} last={i === sortedRows.length - 1}
              rootRef={(el) => { rowRefs.current[row.title] = el; }}
              open={openTitle === row.title}
              onToggle={() => setOpenTitle((t) => (t === row.title ? null : row.title))} />
          ))
        )}
      </div>
      {Footer ? <Footer /> : null}
    </div>
  );
}

window.CategoryPage = CategoryPage;
window.CP_statusColor = statusColor;
window.CrumbBar = CrumbBar;
// Exposed so SubcategoryPage can give the graphics index its own architecture-
// style index ⇄ gallery toggle, reusing the exact matrix row, view toggle,
// sort caret, and sort comparator without duplicating their logic.
window.CP_Row = CP_Row;
window.CP_ViewToggle = CP_ViewToggle;
window.CP_SortCaret = SortCaret;
window.CP_sortValue = sortValue;
