/* Entry detail view + redaction-decrypt spoiler + appearance-map timeline */
const { useState, useRef } = React;

/* Single art slot per entry. Each entry can show AI-generated placeholder art
   shipped in /prototype/assets/art (mapped in window.DCC_SEED_ART). A reader's
   own upload (drag-drop, persisted via <image-slot>) always overrides the
   placeholder; clearing the upload reveals the placeholder again — the same
   precedence the <image-slot> `src` fallback already implements. Where to
   legitimately *find* real fan/official art is still surfaced via each entry's
   `artRef` credits (see ArtRefs / data.js / DCCArt). */
const ART_SLOT = entryId => 'art-' + entryId;

/* Owner-approved community submissions override the shipped placeholder art.
   We fetch the small manifest of approved entry-ids once; entries in it resolve
   to the /api/art endpoint instead of the shipped seed. A reader's own upload
   still wins over both. */
const _commSubs = new Set();
function loadCommunityArt(){
  if(window.__dccCommReq) return; window.__dccCommReq = true;
  fetch('/api/community').then(r=>r.ok?r.json():{entries:[]}).then(d=>{
    const m={}; (d.entries||[]).forEach(e=>{ m[e]=1; });
    window.DCC_COMMUNITY_ART = m; _commSubs.forEach(f=>{ try{f();}catch(e){} });
  }).catch(()=>{ window.DCC_COMMUNITY_ART = window.DCC_COMMUNITY_ART || {}; });
}
loadCommunityArt();

/* A manifest value is a single path string OR an array of paths — entries can
   ship multiple images (e.g. Mordecai's per-floor forms; later, multiple
   approved fan submissions). artList() normalizes to an array; seedArt() returns
   the PRIMARY (first) image, which is what thumbnails and the <image-slot> src
   fallback consume. seedArtGallery() returns the whole list for the detail-page
   gallery. */
function artList(entryId){
  const v = window.DCC_SEED_ART && window.DCC_SEED_ART[entryId];
  if(!v) return [];
  // Manifest paths are site-root-relative; root them so they also resolve
  // from prerendered subpaths like /entry/<id>/.
  const abs = p => (/^(\/|https?:|data:)/.test(p)) ? p : '/' + p;
  return (Array.isArray(v) ? v.filter(Boolean) : [v]).map(abs);
}
function seedArt(entryId){
  if(window.DCC_COMMUNITY_ART && window.DCC_COMMUNITY_ART[entryId])
    return '/api/art?entry=' + encodeURIComponent(entryId);
  const l = artList(entryId);
  return l.length ? l[0] : null;
}
function seedArtGallery(entryId){
  if(window.DCC_COMMUNITY_ART && window.DCC_COMMUNITY_ART[entryId])
    return ['/api/art?entry=' + encodeURIComponent(entryId)];
  return artList(entryId);
}

function TypeTag({type, outline}){
  const label = window.DCC.typeName[type] || type;
  const cls = outline ? 'etag-out' : 'etype';
  return <span className={cls} style={{['--tc']:`var(--t-${type})`}}>{label}</span>;
}

/* The highest book number a stat value names, e.g. "Killed (B3 · C20)" -> 3.
   A value is a spoiler when that book is past what the reader is cleared for. */
function statMaxBook(value){
  let max=0;
  String(value).replace(/B(\d+)/g, (m,n)=>{ const k=+n; if(k>max) max=k; return m; });
  return max;
}
/* Shared stat row. A stat value naming a book past the reader's clearance
   redacts to ▓▓ — keeping deaths/race-changes off an early-book card the way
   the appearance map and event log do. revealBook = how far this reader is
   cleared for THIS entry (their reading level, but never below the debut book). */
function StatRow({entry}){
  const [level] = window.useReaderLevel();
  const revealBook = Math.max(level||0, entry.book||1);
  if(!entry.stats || !entry.stats.length) return null;
  return (
    <div className="stat-row">
      {entry.stats.map((s,i)=>{
        const sealed = statMaxBook(s[1]) > revealBook;
        return <span className={'stat'+(sealed?' sealed':'')} key={i}>{s[0]}: <b>{sealed ? '▓▓ sealed' : s[1]}</b></span>;
      })}
    </div>
  );
}

/* Derived "quick facts" infobox — a richer, wiki-style stat block computed from
   the entry's own data (no per-entry authoring needed). Spoiler-safe: every
   count is taken only from books the reader is cleared for, so it can't leak
   that a character recurs in a book they haven't reached. */
function InfoBox({entry, revealBook}){
  const D = window.DCC;
  const mentions = (entry.mentions||[]).filter(m=>(m.book||entry.book)<=revealBook);
  const books = [...new Set([entry.book].concat(mentions.map(m=>m.book||entry.book)))].sort((a,b)=>a-b);
  let boxCount = 0;
  D.allEntries().forEach(b=>{ if(b.type!=='lootbox') return;
    (b.awards||[]).forEach(a=>{ if(a.to && a.to.indexOf(entry.id)>=0 && a.b<=revealBook) boxCount+=(a.qty||1); }); });
  const relCount = (entry.related||[]).filter(id=>D.ENTRIES[id]).length;
  const facts = [['Type', D.typeName[entry.type]||entry.type], ['First logged', 'B'+entry.book+'·C'+entry.chapter]];
  if(books.length) facts.push(['Appears in', books.length>1 ? ('Books '+books[0]+'–'+books[books.length-1]) : ('Book '+books[0])]);
  if(mentions.length) facts.push(['Logged beats', String(mentions.length)]);
  if(boxCount) facts.push(['Loot boxes', String(boxCount)]);
  if(relCount) facts.push(['Related', String(relCount)]);
  return (
    <div className="infobox">
      {facts.map((f,i)=>(<div className="ib-row" key={i}><span className="ib-k">{f[0]}</span><span className="ib-v">{f[1]}</span></div>))}
    </div>
  );
}

/* Read an entry's art for grid/index thumbnails. Precedence: a reader's saved
   upload (window.ImageSlots sidecar) wins; otherwise the shipped placeholder
   (seedArt). Live-updates when art is dropped or the sidecar finishes loading,
   so the right image shows EVERYWHERE the entry appears, not just its detail
   page. `seed` is returned separately so the read-only display slot can carry
   it as its src fallback. */
function useArtImage(entryId){
  const [st, setSt] = useState(()=>{ const s=seedArt(entryId); return {url:s, slot:ART_SLOT(entryId), seed:s}; });
  React.useEffect(()=>{
    const id = ART_SLOT(entryId);
    const read = ()=>{
      const s = seedArt(entryId);                       // recompute (community art may have just loaded)
      const u = window.ImageSlots ? window.ImageSlots.get(id) : null;
      setSt({url:u || s, slot:id, seed:s});
    };
    const unsubSlots = window.ImageSlots ? window.ImageSlots.subscribe(read) : null;
    _commSubs.add(read);                                // re-read when /api/community resolves
    read();
    return ()=>{ if(unsubSlots) unsubSlots(); _commSubs.delete(read); };
  }, [entryId]);
  return st;
}

/* Grid/index thumbnail. Renders the saved art through a READ-ONLY image-slot
   (display mode) — the very same component, fit and stored crop the detail
   page uses — so a character looks identical in the index, the landing
   monitor, and on their entry page. Falls back to the striped placeholder. */
function ArtThumb({entry, label}){
  const {url, slot, seed} = useArtImage(entry.id);
  const meta = (window.DCC.TYPE_META && window.DCC.TYPE_META[entry.type]) || {};
  return (
    <div className={'ethumb'+(url?' filled':'')}>
      <TypeTag type={entry.type}/>
      {url
        ? <image-slot id={slot} display="1" fit="contain" shape="rect" className="ethumb-slot" src={seed||undefined}
            style={{position:'absolute', inset:0, width:'100%', height:'100%', display:'block'}}></image-slot>
        : <span className="art-ph" aria-hidden="true">{meta.glyph || '◆'}</span>}
    </div>
  );
}

/* Thumbnail for the "snippet" list/grid cards (lootbox / event / spell / lore /
   notification). Unlike ArtThumb it renders NOTHING when the entry has no art,
   so art-less snippet entries keep their compact text card — only entries with
   seeded or uploaded art gain a thumbnail. Same display-mode <image-slot> as
   ArtThumb so the picture matches the detail page exactly. */
function SnipThumb({entry}){
  const {url, slot, seed} = useArtImage(entry.id);
  if(!url) return null;
  return (
    <div className="ethumb filled">
      <TypeTag type={entry.type}/>
      <image-slot id={slot} display="1" fit="contain" shape="rect" className="ethumb-slot" src={seed||undefined}
        style={{position:'absolute', inset:0, width:'100%', height:'100%', display:'block'}}></image-slot>
    </div>
  );
}

/* Inline cross-links. A description links the proper nouns it actually
   names — characters, creatures and places — plus anything in this entry's
   curated `related` list (which also covers short forms like "Bea"/"Donut").
   Matching is case-sensitive with letter boundaries so common words
   ("torch", "poker") never false-trigger, and each target links at most once
   (its first mention). Self-references are skipped. */
function escRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); }
function nameTerms(nm, minLen, withLastWord){
  const set = new Set([nm]);
  const stripped = nm.replace(/\s*\([^)]*\)\s*/g,' ').replace(/\s+/g,' ').trim();
  if(stripped) set.add(stripped);
  const paren = (nm.match(/\(([^)]+)\)/)||[])[1];
  if(paren) set.add(paren.trim());
  if(withLastWord){ const w = stripped.split(' '); if(w.length>1 && w[w.length-1].length>=4) set.add(w[w.length-1]); }
  return [...set].filter(t=>t && t.length>=minLen);
}
function globalTerms(){
  if(window.__dccGlobalTerms) return window.__dccGlobalTerms;
  const list = [];
  window.DCC.allEntries().forEach(t=>{
    if(!(t.type==='character'||t.type==='creature'||t.type==='place')) return;
    nameTerms(t.name, 4, false).forEach(term=>list.push({term, id:t.id}));
  });
  window.__dccGlobalTerms = list;
  return list;
}
function relatedTerms(entry){
  const E = window.DCC.ENTRIES, list = [];
  (entry.related||[]).forEach(id=>{
    const t = E[id]; if(!t || id===entry.id) return;
    nameTerms(t.name, 3, true).forEach(term=>list.push({term, id}));
  });
  return list;
}
function buildTerms(entry){
  const all = relatedTerms(entry).concat(globalTerms().filter(x=>x.id!==entry.id));
  const map = new Map();
  for(const {term,id} of all){ if(!map.has(term)) map.set(term, id); }   // related first → wins ties
  const terms = [...map.keys()].sort((a,b)=>b.length-a.length);          // longest-first alternation priority
  return {map, terms};
}
function linkify(text, entry, go){
  if(!text || typeof text!=='string') return text;
  const {map, terms} = buildTerms(entry);
  if(!terms.length) return text;
  let re;
  try{ re = new RegExp('(?<![A-Za-z0-9])('+terms.map(escRe).join('|')+')(?![A-Za-z0-9])','g'); }
  catch(e){ return text; }
  const used = new Set(), nodes = [];
  let last = 0, m;
  while((m = re.exec(text))){
    const id = map.get(m[1]);
    if(!id || id===entry.id || used.has(id)) continue;
    used.add(id);
    if(m.index>last) nodes.push(text.slice(last, m.index));
    nodes.push(<a className="xlink" key={'x'+m.index} onClick={e=>{e.stopPropagation(); go({view:'entry', id});}}>{m[1]}</a>);
    last = m.index + m[1].length;
  }
  if(last < text.length) nodes.push(text.slice(last));
  return nodes.length ? nodes : text;
}

/* Canon naming convention: the System appends a number to crawlers who
   share a name with an already-registered crawler (Chris Andrews 2,
   Yolanda Martinez 13…). Explain it so it doesn't read as a typo. */
function DupNameNote({entry}){
  if(entry.type!=='character') return null;
  const m = /^(.+?)\s+(\d+)$/.exec(entry.name);
  if(!m) return null;
  return (
    <div className="dupnote"><span className="sig">※</span> Not a typo. The System numbers duplicate names. Another crawler had already registered “{m[1]}”.</div>
  );
}

/* One reference-art card. Where a ref carries a `thumb`, the image is HOTLINKED
   (loaded straight from the rights-holder's host) — never copied or re-hosted,
   so the bytes always live with the owner. If the host blocks the embed (hotlink
   protection / 404) OR the ref has no thumb (e.g. fan galleries, where we won't
   embed an individual artist's piece without permission), the card falls back to
   a themed "Warning Broadcast" tile that still links out with full credit. */
function ArtRefCard({r}){
  const [broken, setBroken] = useState(false);
  const showImg = r.thumb && !broken;
  return (
    <a className="art-card" href={r.url} target="_blank" rel="noopener noreferrer">
      <div className={'art-card-thumb' + (showImg ? '' : ' is-tile')}>
        {showImg ? (
          <img src={r.thumb} alt={r.label} loading="lazy" decoding="async"
            referrerPolicy="no-referrer" onError={()=>setBroken(true)} />
        ) : (
          <div className="art-tile" aria-hidden="true">
            {/* Self-made "gallery" glyph (stacked frames). We intentionally do
                NOT embed any individual fan artist's piece here — this tile
                stands in for the link-out so the card reads as designed, not
                as a missing image. */}
            <svg className="art-tile-art" viewBox="0 0 64 56" fill="none"
              stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" strokeLinecap="round">
              <rect x="9" y="6" width="34" height="30" rx="3" className="frame-back"/>
              <rect x="21" y="18" width="34" height="30" rx="3" className="frame-front"/>
              <circle cx="30" cy="29" r="3.4" className="frame-sun"/>
              <path d="M23 41 L32 31 L38 37 L45 30 L53 41" className="frame-hill"/>
            </svg>
            <span className="art-tile-cap">{r.platform || 'View at source'}</span>
            <span className="art-tile-chip">Browse gallery →</span>
          </div>
        )}
        {r.kind && <span className={'art-card-kind k-' + (r.kind.split(' ')[0])}>{r.kind}</span>}
        <span className="art-card-go">View ↗</span>
      </div>
      <div className="art-card-meta">
        <div className="art-card-label">{r.label}</div>
        {r.platform && <div className="art-card-plat">{r.platform}</div>}
        {r.credit && <div className="art-card-credit">{r.credit}</div>}
      </div>
    </a>
  );
}

/* Reference-art gallery: where to legitimately VIEW real art for this entry.
   Hotlink + credit only — we never download or re-host. Thumbnails (where shown)
   are embeds pointing at the rights-holder's server. Renders entry.artRef (from
   data.js / DCCArt). Renders nothing if an entry has no known sources. */
function ArtRefs({entry}){
  const refs = entry.artRef || [];
  if(!refs.length) return null;
  return (
    <div className="art-ref">
      <div className="art-ref-h">Reference art <span className="art-ref-sub">· viewed at source · hosted by the rights-holder · credit the artist</span></div>
      <div className="art-card-grid">
        {refs.map((r,i)=>(<ArtRefCard key={i} r={r} />))}
      </div>
    </div>
  );
}

/* Art slot built on the <image-slot> web component. Shows the shipped
   AI-generated placeholder (src=seedArt) by default; a reader can drop/upload
   their OWN art, which persists to the sidecar (shareable) under a stable id —
   art-<entry> — and overrides the placeholder everywhere this entry appears.
   Clearing the upload reveals the placeholder again. ArtRefs still surfaces
   where to find real, credited artwork. */
function ArtStack({entry, go}){
  const {url, seed} = useArtImage(entry.id);
  const gallery = seedArtGallery(entry.id);
  const showingUpload = !!(url && url !== seed);
  const [sel, setSel] = useState(0);
  // Offer the gallery only for shipped multi-image entries, and only while the
  // reader's own upload isn't overriding the slot. Switching a thumb changes the
  // hero <image-slot> src — the component re-renders the fallback on src change.
  const multi = gallery.length > 1 && !showingUpload;
  const i = Math.min(sel, gallery.length - 1);
  const heroSrc = multi ? gallery[i] : (seed || undefined);
  return (
    <div className="art-stack">
      <div className="slotwrap hero">
        <image-slot id={ART_SLOT(entry.id)} className="islot" shape="rounded" radius="8" fit="contain"
          src={heroSrc}
          placeholder={'Upload your art: '+entry.name}
          style={{display:'block', width:'100%', height:'330px'}}></image-slot>
        <span className="art-badge">{showingUpload ? 'Your upload' : (seed ? 'System AI Concept Art' : 'Your upload')}</span>
      </div>
      {multi && (
        <div className="art-gallery" role="tablist" aria-label={entry.name+' images'}>
          {gallery.map((src, gi)=>(
            <button key={gi} type="button" className={'art-gthumb'+(gi===i?' is-sel':'')}
              role="tab" aria-selected={gi===i} onClick={()=>setSel(gi)} title={'Image '+(gi+1)}>
              <img src={src} alt="" loading="lazy" decoding="async"/>
            </button>
          ))}
        </div>
      )}
      <div className="art-note">Concept art drawn by the System AI. Think you can do better? <a onClick={()=>go && go({view:'submit', id:entry.id})} style={{cursor:'pointer', textDecoration:'underline'}}>Submit your own ▸</a>. We review and host the good ones.</div>
      <ArtRefs entry={entry}/>
    </div>
  );
}

/* Appearance map. A node from a LATER book than the entry's debut is a
   future-book spoiler: redacted until the reader decrypts. One rule, no
   manual flags — add a real node and it seals/reveals itself correctly. */
function Timeline({entry, revealBook}){
  return (
    <div className="timeline">
      <div className="tl-track">
        {entry.timeline.map((n,i)=>{
          const later = n.book > entry.book;
          const sealed = n.book > revealBook;
          const cls = 'tl-node'+(n.first?' first':'')+(later?' later':'')+(sealed?' sealed':'');
          return (
            <div key={i} className={cls}>
              <span className="dot"></span>
              <span className="loc">{`B${n.book}·C${n.ch}`}</span>
              <span className="lab">{n.first?'First appearance':(n.label||'Reappears')}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* One clearance control for the whole entry: a single toggle that unseals
   every later-book element at once (appearance map, per-book events, form
   log, future refs). Shown only when the entry actually has sealed content. */
function DecryptBar({entry, level, setLevel, revealBook}){
  let maxBook = entry.book;
  (entry.mentions||[]).forEach(m=>{ const b=m.book||entry.book; if(b>maxBook) maxBook=b; });
  (entry.timeline||[]).forEach(n=>{ if(n.book>maxBook) maxBook=n.book; });
  const sm = sheetMaxBook(entry.sheet); if(sm>maxBook) maxBook=sm;
  const hasFuture = entry.futureRefs && entry.futureRefs.length>0;
  const fully = revealBook>=maxBook && (!hasFuture || level>=99);
  return (
    <div className={"decrypt-bar"+(fully?' is-on':'')}>
      <div className="db-warn"><span className="ic">{fully?'◈':'▓'}</span>{fully?'Clearance granted':'Later-book intel · sealed'}</div>
      <div className="db-mid">{fully
        ? `Everything the System logged about ${entry.name} is now visible.`
        : (level>=entry.book && level>0
            ? `You're cleared through Book ${revealBook}. ${entry.name} recurs through Book ${maxBook}. Reveal the rest at your own risk, Crawler.`
            : `${entry.name} recurs in later books. Reveal those events at your own risk, Crawler.`)}</div>
      <button className="decrypt-btn" onClick={()=>setLevel(fully?0:99)}>{fully?'Re-seal ◂':'Decrypt all ▸'}</button>
    </div>
  );
}

/* Per-book “major events” — the chapter beats, grouped by book. A book
   later than the debut book is a spoiler: its notes stay redacted until the
   reader decrypts. mentions[].book defaults to the entry's debut book. */
function MajorEvents({entry, revealBook, go}){
  if(!entry.mentions || !entry.mentions.length) return null;
  const byBook = {};
  entry.mentions.forEach(m=>{ const b=m.book||entry.book; (byBook[b]=byBook[b]||[]).push(m); });
  const books = Object.keys(byBook).map(Number).sort((a,b)=>a-b);
  const beatKey = new Set((entry.timeline||[]).map(n=>n.book+'-'+n.ch));
  return (
    <div className="section" style={{paddingBottom:0}}>
      {beatKey.size>0 && <div className="mev-key"><b className="beatdot">◆</b> a beat charted on the Character Timeline · the rest are other logged appearances</div>}
      {books.map(b=>{
        const later = b>entry.book;
        const sealed = b>revealBook;
        return (
          <div className="mbook" key={b}>
            <div className="block-h">Appears in Book {b}{later?(sealed?' · sealed':' · later book'):''}</div>
            <div className="mentions">
              {byBook[b].map((m,i)=>{
                const isBeat = beatKey.has(b+'-'+m.ch);
                return (
                <div className={"mention"+(isBeat?' isbeat':'')+(sealed?' sealed':'')} key={i}
                  onClick={()=> sealed ? null : go({view:'chapter', book:b, ch:m.ch})}>
                  <span className="loc">{isBeat && <b className="beatdot">◆</b>}B{b}·C{m.ch}</span>
                  <span className="mtxt">{m.note}</span>
                  {sealed ? <i className="mbar"></i> : <span className="arr">↗</span>}
                </div>
                );
              })}
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* Form log — for shapeshifters & floor-reassigned NPCs (Mordecai, Katia…).
   Rows from the entry's first book are always visible; later-book forms
   stay redacted until the reader decrypts (same clearance state as the
   future-refs spoiler). */
function FormLog({entry, revealBook}){
  if(!entry.formLog || !entry.formLog.length) return null;
  const allOpen = !entry.formLog.some(r=>r.b>revealBook);
  return (
    <div className={"spoiler redact"+(allOpen?' is-on':'')}>
      <div className="sp-top">
        <div className="sp-warn"><span className="ic">{allOpen?'◈':'▓'}</span>{allOpen?'Form log · full record':'Form log · later forms sealed'}</div>
      </div>
      <p className="sp-desc">The dungeon issues {entry.name} a new body as the floors change. Forms past your reading level stay redacted until you raise it.</p>
      {entry.formLog.map((r,i)=>{
        const open = r.b<=revealBook;
        return (
          <div className="xref" key={i}>
            <div className="rloc">{'B'+r.b+'·C'+r.c}</div>
            <div className="rtxt">
              <span className="inner" style={{filter: open ? 'none' : 'blur(3px)'}}>
                <b className="formname">{r.form}</b>: {r.note}
              </span>
              <i className="bar" style={{transform: open ? 'scaleX(0)' : 'scaleX(1)'}}></i>
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* Loot box ledger — per-book tally of boxes the System logged to this
   character. Counts only awards whose recipient was broadcast (awards[].to).
   Books later than the debut stay sealed until the reader decrypts. */
const BOX_TIERS = ['Bronze','Silver','Gold','Platinum','Legendary','Celestial','Mixed'];
const BOX_TIER_COLOR = {Bronze:'#cd8a4f', Silver:'#cdd5dc', Gold:'#ffd34d', Platinum:'#8be9e2', Legendary:'#b98bff', Celestial:'#fff3f9', Mixed:'#9a8a92'};
function boxTier(box){
  const t = ((box.stats||[]).find(s=>/^tier$/i.test(s[0]))||[])[1] || box.sub || '';
  const m = /bronze|silver|gold|platinum|legendary|celestial/i.exec(String(t));
  return m ? m[0][0].toUpperCase()+m[0].slice(1).toLowerCase() : 'Mixed';
}
/* tier mini-bar: a thin proportional band of a book's tier mix. Used in the
   collapsed book header and the lifetime summary so tier colour is the one
   constant primitive from summary → book → chip. */
function tierMiniBar(tiers, cls){
  const tot = BOX_TIERS.reduce((n,t)=>n+(tiers[t]||0),0);
  if(!tot) return null;
  return (
    <span className={'led-minibar'+(cls?' '+cls:'')}>
      {BOX_TIERS.filter(t=>tiers[t]).map(t=>(
        <span key={t} style={{width:(tiers[t]/tot*100)+'%', background:BOX_TIER_COLOR[t]}}></span>
      ))}
    </span>
  );
}

function BoxLedger({entry, revealBook, go}){
  const D = window.DCC;
  const byBook = {};
  D.allEntries().forEach(box=>{
    if(box.type!=='lootbox') return;
    (box.awards||[]).forEach(a=>{
      if(!a.to || a.to.indexOf(entry.id)<0) return;
      const q = a.qty||1;                 /* a single award row can be a batch of N identical boxes */
      const r = byBook[a.b] = byBook[a.b] || {tiers:{}, list:[], count:0};
      const tier = boxTier(box);
      r.tiers[tier] = (r.tiers[tier]||0)+q;
      r.count += q;
      r.list.push({box, b:a.b, c:a.c, tier, qty:q});
    });
  });
  const books = Object.keys(byBook).map(Number).sort((a,b)=>a-b);
  const sealedOf = b => b>revealBook;

  /* default open: the character's debut book if present, else the first
     visible book. Other books collapse to their tier mini-bar so the ledger
     stays a uniform, comparable stack as more books are mapped. */
  const visibleBooks = books.filter(b=>!sealedOf(b));
  const firstOpen = visibleBooks.indexOf(entry.book)>=0 ? entry.book : visibleBooks[0];
  const [open, setOpen] = useState({});
  if(!books.length) return null;
  const isOpen = b => (b in open) ? open[b] : (b===firstOpen);
  const toggle = b => setOpen(s=>({...s, [b]: !isOpen(b)}));

  const total = books.reduce((n,b)=>n+byBook[b].count,0);
  /* lifetime summary across what the reader can currently see */
  const lifeTiers = {}; let visTotal = 0;
  visibleBooks.forEach(b=>{ visTotal += byBook[b].count;
    for(const t in byBook[b].tiers) lifeTiers[t]=(lifeTiers[t]||0)+byBook[b].tiers[t]; });
  const sealedCount = books.length - visibleBooks.length;
  const span = visibleBooks.length>1
    ? `BOOKS ${visibleBooks[0]}–${visibleBooks[visibleBooks.length-1]}`
    : (visibleBooks.length ? `BOOK ${visibleBooks[0]}` : '');

  return (
    <div className="section" style={{paddingBottom:0}}>
      <div className="block-h">Loot box ledger · {total} logged</div>

      {visTotal>0 && (
        <div className="rec-sum">
          <div className="rec-sum-top">
            <div className="rec-sum-big">{visTotal}<small>BOX{visTotal===1?'':'ES'} LOGGED · {span}</small></div>
            {sealedCount>0 && <div className="rec-sum-meta">{sealedCount} later book{sealedCount===1?'':'s'} sealed · decrypt to reveal</div>}
          </div>
          <div className="tierbar">
            {BOX_TIERS.filter(t=>lifeTiers[t]).map(t=>(
              <span key={t} style={{width:(lifeTiers[t]/visTotal*100)+'%', background:BOX_TIER_COLOR[t]}}></span>
            ))}
          </div>
          <div className="tierkey">
            {BOX_TIERS.filter(t=>lifeTiers[t]).map(t=>(
              <b key={t}><i style={{background:BOX_TIER_COLOR[t]}}></i>{t} <em>· {lifeTiers[t]}</em></b>
            ))}
          </div>
        </div>
      )}

      <div className="ledger">
        {books.map(b=>{
          const sealed = sealedOf(b);
          const r = byBook[b];
          const meta = (D.BOOKS||[]).find(x=>x.n===b);
          const o = isOpen(b);
          return (
            <div className={'led-book'+(sealed?' sealed':'')} key={b}>
              <div className="led-head" onClick={()=>toggle(b)}>
                <span className="led-caret">{o?'▾':'▸'}</span>
                <span className="led-bn">BOOK {b}</span>
                {meta && <span className="led-bt">{meta.title}</span>}
                {!sealed && tierMiniBar(r.tiers)}
                <span className="led-tot"><b>{sealed?'▓▓':r.count}</b> box{r.count===1?'':'es'}{sealed?' · sealed':''}</span>
              </div>
              {o && (
                <div className="led-body">
                  <div className="led-tiers">
                    {BOX_TIERS.filter(t=>r.tiers[t]).map(t=>(
                      <span className="led-tier" key={t} style={{['--lc']:BOX_TIER_COLOR[t]}}>
                        <b>{sealed?'?':r.tiers[t]}×</b> {t}
                      </span>
                    ))}
                  </div>
                  {sealed ? (
                    <div className="led-collapsed-note">▓▓▓▓ contents sealed. Raise clearance to reveal Book {b}</div>
                  ) : (
                    <div className="led-list">
                      {r.list.map((x,i)=>(
                        <span className="led-box" key={i} style={{['--lc']:BOX_TIER_COLOR[x.tier]}}
                          onClick={(ev)=>{ ev.stopPropagation(); go({view:'entry', id:x.box.id}); }}>
                          {x.box.name}{x.qty>1?<em style={{fontWeight:800,fontStyle:'normal',margin:'0 .1em 0 .4em',opacity:.9}}>×{x.qty}</em>:null}<i>C{x.c}</i>
                        </span>
                      ))}
                    </div>
                  )}
                </div>
              )}
            </div>
          );
        })}
      </div>
      <div className="led-note">Only drops whose recipient was broadcast are tallied. Box types and unattributed drops live in the <a onClick={()=>go({view:'type', type:'lootbox'})}>Loot Box index</a>.</div>
    </div>
  );
}

function RedactionSpoiler({entry, decrypted, go}){
  const E = window.DCC.ENTRIES;
  return (
    <div className={"spoiler redact"+(decrypted?' is-on':'')}>
      <div className="sp-top">
        <div className="sp-warn"><span className="ic">{decrypted?'◈':'▓'}</span>{decrypted?'Decrypted · future-book teasers':'Future-book teasers · sealed'}</div>
      </div>
      <p className="sp-desc">Vague glimpses of what's still ahead for {entry.name}. Revealed with the clearance toggle above.</p>
      {entry.futureRefs.map((r,i)=>(
        <div className="xref" key={i}>
          <div className="rloc">{r.loc}</div>
          <div className="rtxt">
            <span className="inner" style={{filter: decrypted ? 'none' : 'blur(3px)'}}>
              {r.text}
              {r.link && E[r.link] && (
                <span className="rlink" style={{visibility: decrypted ? 'visible' : 'hidden'}} onClick={()=>go({view:'entry', id:r.link})}>→ see {E[r.link].name}</span>
              )}
            </span>
            <i className="bar" style={{transform: decrypted ? 'scaleX(0)' : 'scaleX(1)'}}></i>
          </div>
        </div>
      ))}
    </div>
  );
}

/* One occurrence in the occurrence ledger. Collapsed it shows a location + a
   one-line preview; clicking expands a full summary of THAT specific time the
   box was awarded (or the event occurred): who received it, what it held, and
   a jump to the chapter. This is the fix for "the page only describes the
   first award" — every recurrence now carries its own summary. */
function OccRow({entry, o, go, defaultOpen}){
  const E = window.DCC.ENTRIES;
  const [open, setOpen] = useState(!!defaultOpen);
  const people = (o.to||[]).map(id=>E[id]).filter(Boolean);
  const chTitle = (window.DCC.chapter(o.b, o.c)||{}).title || '';
  const fallback = people.length
    ? 'Awarded to '+people.map(p=>p.name).join(' & ')+(chTitle?' · '+chTitle:'')
    : (chTitle || 'Open this occurrence');
  const isBox = entry.type==='lootbox';
  return (
    <div className={'occ-ev'+(open?' open':'')}>
      <button className="occ-ev-head" onClick={()=>setOpen(v=>!v)}>
        <span className="occ-ev-loc">B{o.b}·C{o.c}</span>
        {o.qty>1 && <span className="occ-ev-qty">×{o.qty}</span>}
        <span className="occ-ev-prev">{open ? (chTitle || ('Chapter '+o.c)) : (o.note || fallback)}</span>
        <span className="occ-ev-caret">{open?'▾':'▸'}</span>
      </button>
      {open && (
        <div className="occ-ev-body">
          {o.note
            ? <p className="occ-ev-note">{linkify(o.note, entry, go)}</p>
            : <p className="occ-ev-note dim">No play-by-play on this one. Jump to the chapter for the full scene.</p>}
          <div className="occ-ev-meta">
            {people.length>0 && (
              <div className="occ-ev-to">
                <span className="lab">{isBox?'Awarded to':'Involves'}</span>
                {people.map(p=>(
                  <span className="occ-person" key={p.id} onClick={()=>go({view:'entry', id:p.id})}>{p.name}</span>
                ))}
                {o.qty>1 && <span className="occ-ev-each">· {o.qty} each</span>}
              </div>
            )}
            <a className="occ-ev-goch" onClick={()=>go({view:'chapter', book:o.b, ch:o.c})}>
              Open B{o.b}·C{o.c}{chTitle?' · '+chTitle:''} ↗
            </a>
          </div>
        </div>
      )}
    </div>
  );
}

/* Compact snippet view for loot boxes & notifications: no art, just the
   basic record (rarity / type / contents) plus an OCCURRENCE INDEX — the
   count and chapter list that powers the "X total awarded" goal. */
function SnippetEntry({entry, go, prev, next}){
  const E = window.DCC.ENTRIES;
  const {url:artUrl, seed:artSeed} = useArtImage(entry.id);
  const occ = (entry.awards && entry.awards.length) ? entry.awards
            : (entry.mentions && entry.mentions.length) ? entry.mentions.map(m=>({b:m.book||entry.book,c:m.ch,note:m.note}))
            : [{b:entry.book,c:entry.chapter}];
  const occTotal = occ.reduce((n,o)=>n+(o.qty||1),0);
  const isBox = entry.type==='lootbox';
  const occVerb = isBox ? 'awarded' : (entry.type==='spell' ? 'appears' : (entry.type==='event' ? 'occurs' : (entry.type==='lore' ? 'noted' : 'logged')));
  const occThing = isBox ? 'box is awarded' : (entry.type==='spell' ? 'spell/skill appears' : (entry.type==='event' ? 'event unfolds' : (entry.type==='lore' ? 'lore is recorded' : 'notification fires')));
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs">
        <a onClick={()=>go({view:'books'})}>Books</a><span className="sep">/</span>
        <a onClick={()=>go({view:'book', book:entry.book})}>Book {entry.book}</a><span className="sep">/</span>
        <a onClick={()=>go({view:'chapter', book:entry.book, ch:entry.chapter})}>Ch. {entry.chapter}</a><span className="sep">/</span>
        <span style={{color:'var(--ink)'}}>{entry.name}</span>
      </div>
      <div className="entry-hero">
        <div><TypeTag type={entry.type}/>{entry.kind && window.DCC.kindName[entry.kind] && <span className="snip-tier" style={{marginLeft:8}}>{window.DCC.kindName[entry.kind]}</span>}<h1 className="eh-name">{entry.name}</h1><div className="eh-sub">{entry.sub}</div></div>
        <div className="first-stamp">First logged<b>BOOK {entry.book} · CHAPTER {entry.chapter}</b></div>
      </div>
      <div className="snippet">
        {artUrl && (
          <div className="slotwrap hero snip-art" style={{marginBottom:'18px'}}>
            <image-slot id={ART_SLOT(entry.id)} className="islot" shape="rounded" radius="8" fit="contain"
              src={artSeed||undefined}
              placeholder={'Upload your art: '+entry.name}
              style={{display:'block', width:'100%', height:'300px'}}></image-slot>
            <span className="art-badge">{(artUrl && artUrl!==artSeed) ? 'Your upload' : 'System AI Concept Art'}</span>
          </div>
        )}
        <p className="desc">{linkify(entry.desc, entry, go)}</p>
        <StatRow entry={entry}/>
        <div className="block-h">Occurrence index · {occVerb} {occTotal}×{occTotal!==occ.length?(' over '+occ.length+' logs'):''}</div>
        <div className="occ-summary">
          <span className="occ-count">{occTotal}</span>
          <div className="occ-sumtxt">
            <b>{occVerb} {occTotal} time{occTotal===1?'':'s'}</b>
            <span>Tap any log below for a summary of that {isBox?'drop':'occurrence'}: who got it and what it held.</span>
          </div>
        </div>
        <div className="occ-led">
          {occ.map((o,i)=><OccRow key={i} entry={entry} o={o} go={go} defaultOpen={i===0}/>)}
        </div>
        {entry.related && entry.related.length>0 && (
          <React.Fragment>
            <div className="block-h">{isBox?'Contents / related':'Related entries'}</div>
            <div style={{display:'flex',flexWrap:'wrap',gap:'10px'}}>
              {entry.related.map(id=> E[id] ? (
                <div key={id} className="mention" style={{cursor:'pointer',flex:'0 0 auto'}} onClick={()=>go({view:'entry', id})}>
                  <TypeTag type={E[id].type} outline/><span style={{fontFamily:'var(--f-gothic)',fontWeight:700,fontSize:'17px'}}>{E[id].name}</span>
                </div>
              ) : null)}
            </div>
          </React.Fragment>
        )}
      </div>
      <div className="entry-nav">
        {prev ? <a onClick={()=>go({view:'entry', id:prev.id})}><div className="lab">◂ Previous entry</div><div className="nm">{prev.name}</div></a> : <span style={{flex:'1 1 240px'}}/>}
        {next ? <a className="nx" onClick={()=>go({view:'entry', id:next.id})}><div className="lab">Next entry ▸</div><div className="nm">{next.name}</div></a> : <span style={{flex:'1 1 240px'}}/>}
      </div>
    </div></div>
  );
}

function sheetMaxBook(sheet){
  let m = 0;
  if(!sheet) return m;
  ['progression','skills','attacks'].forEach(k=>{
    (sheet[k]||[]).forEach(r=>{ if(r.book>m) m=r.book; });
  });
  (sheet.armor||[]).forEach(r=>{
    if(r.book>m) m=r.book;
    (r.held||[]).forEach(h=>{ if(h.book>m) m=h.book; });
  });
  return m;
}
function sheetHasSpoiler(sheet, revealBook){
  if(!sheet) return false;
  for(const k of ['progression','skills','attacks']){
    if((sheet[k]||[]).some(r=>r.book>revealBook)) return true;
  }
  if((sheet.armor||[]).some(r=>r.book>revealBook)) return true;
  if((sheet.armor||[]).some(r=>(r.held||[]).some(h=>h.book>revealBook))) return true;
  return false;
}
function SrcChip({src}){
  if(!src) return null;
  const est = src==='est';
  return <span className={'cs-src'+(est?' est':'')} title={est?'Fan estimate — not explicitly stated in the text':'Sourced from the text'}>{est?'est.':'canon'}</span>;
}
function StatLine({stats, statSrc}){
  const cells = [['STR','str'],['CON','con'],['INT','int'],['DEX','dex'],['CHA','cha']];
  return (
    <div className="cs-statline">
      {cells.map(([lab,key])=> stats[key]!=null ? (
        <div className={'cs-stat cs-stat-'+key} key={key}>
          <span className="cs-stat-lab">{lab}</span>
          <span className="cs-stat-val">{stats[key]}</span>
        </div>
      ) : null)}
      <SrcChip src={statSrc}/>
    </div>
  );
}
function CharacterSheet({entry, revealBook, go}){
  const sh = entry.sheet;
  if(!sh) return null;
  const E = window.DCC.ENTRIES;
  const [open, setOpen] = useState({progression:true});
  const isOpen = k => !!open[k];
  const toggle = k => setOpen(s=>({...s, [k]:!s[k]}));
  const lbl = sh.labels || {};
  const panels = [
    {key:'progression', title:lbl.progression||'Class & Stat Progression', icon:'▲', rows:sh.progression},
    {key:'skills',      title:lbl.skills||'Skills & Spells',               icon:'◆', rows:sh.skills},
    {key:'attacks',     title:lbl.attacks||'Signature Attacks',            icon:'✦', rows:sh.attacks},
    {key:'armor',       title:lbl.armor||'Armor & Equipment',              icon:'◈', rows:sh.armor},
  ].filter(p=>p.rows && p.rows.length);
  return (
    <div className="section cs-sheet" style={{paddingBottom:0}}>
      <div className="block-h">Character sheet</div>
      {sh.caption && <div className="cs-caption">{linkify(sh.caption, entry, go)}</div>}
      <div className="cs-legend">
        <span><b className="cs-src">canon</b> sourced from the text</span>
        <span><b className="cs-src est">est.</b> fan estimate</span>
      </div>
      {panels.map(P=>{
        const o = isOpen(P.key);
        const visCount = P.rows.filter(r=>r.book<=revealBook).length;
        const sealCount = P.rows.length - visCount;
        return (
          <div className="cs-panel" key={P.key}>
            <div className="cs-phead" onClick={()=>toggle(P.key)}>
              <span className="cs-caret">{o?'▾':'▸'}</span>
              <span className="cs-icon">{P.icon}</span>
              <span className="cs-ptitle">{P.title}</span>
              <span className="cs-pcount">{visCount}{sealCount>0?(' + '+sealCount+' sealed'):''}</span>
            </div>
            {o && P.key==='progression' && (
              <div className="cs-pbody">
                {P.rows.map((r,i)=>{
                  const sealed = r.book>revealBook;
                  return (
                    <div className={'cs-prog'+(sealed?' sealed':'')} key={i}>
                      <div className="cs-prog-head">
                        <span className="cs-loc">Book {r.book}</span>
                        <span className="cs-prog-level">{sealed?'▓▓':('Lvl '+r.level)}</span>
                        <span className="cs-prog-race">{sealed?'▓▓':r.race}</span>
                        {r.link && !sealed && E[r.link]
                          ? <a className="cs-link" onClick={()=>go({view:'entry',id:r.link})}>{r.cls}</a>
                          : <span className="cs-prog-cls">{sealed?'▓▓':r.cls}</span>}
                        <SrcChip src={r.src}/>
                      </div>
                      {!sealed && r.stats && <StatLine stats={r.stats} statSrc={r.statSrc}/>}
                      {!sealed && r.statNote && <div className="cs-note cs-statnote">{linkify(r.statNote, entry, go)}</div>}
                      {!sealed && r.note && <div className="cs-note">{linkify(r.note, entry, go)}</div>}
                    </div>
                  );
                })}
              </div>
            )}
            {o && P.key==='skills' && (
              <div className="cs-pbody">
                {P.rows.map((r,i)=>{
                  const sealed = r.book>revealBook;
                  return (
                    <div className={'cs-row'+(sealed?' sealed':'')} key={i}>
                      <span className="cs-loc">B{r.book}{r.ch!=null?'·C'+r.ch:''}</span>
                      {r.kind && !sealed && <span className={'cs-kind k-'+r.kind}>{r.kind}</span>}
                      {r.link && !sealed && E[r.link]
                        ? <a className="cs-link" onClick={()=>go({view:'entry',id:r.link})}>{r.name}</a>
                        : <span className="cs-name">{sealed?'▓▓ sealed':r.name}</span>}
                      <SrcChip src={sealed?null:r.src}/>
                      {!sealed && r.desc && <div className="cs-note">{linkify(r.desc, entry, go)}</div>}
                      {!sealed && r.note && <div className="cs-note cs-note-extra">{linkify(r.note, entry, go)}</div>}
                    </div>
                  );
                })}
              </div>
            )}
            {o && P.key==='attacks' && (
              <div className="cs-pbody">
                {P.rows.map((r,i)=>{
                  const sealed = r.book>revealBook;
                  return (
                    <div className={'cs-row'+(sealed?' sealed':'')} key={i}>
                      <span className="cs-loc">B{r.book}{r.ch!=null?'·C'+r.ch:''}</span>
                      {r.link && !sealed && E[r.link]
                        ? <a className="cs-link" onClick={()=>go({view:'entry',id:r.link})}>{r.name}</a>
                        : <span className="cs-name">{sealed?'▓▓ sealed':r.name}</span>}
                      <SrcChip src={sealed?null:r.src}/>
                      {!sealed && r.desc && <div className="cs-note">{linkify(r.desc, entry, go)}</div>}
                    </div>
                  );
                })}
              </div>
            )}
            {o && P.key==='armor' && (
              <div className="cs-pbody">
                <ArmorPanel rows={P.rows} revealBook={revealBook} entry={entry} go={go}/>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}
function ArmorPanel({rows, revealBook, entry, go}){
  const E = window.DCC.ENTRIES;
  const [openArmor, setOpenArmor] = useState({});
  const toggleA = k => setOpenArmor(s=>({...s, [k]:!s[k]}));
  return rows.map((r,i)=>{
    const sealed = r.book>revealBook;
    const hasHeld = r.held && r.held.length>0;
    const hasDetail = hasHeld || r.note;
    const ao = !!openArmor[i];
    return (
      <div className={'cs-armor'+(sealed?' sealed':'')} key={i}>
        <div className={'cs-armor-head'+(hasDetail?' clickable':'')} onClick={hasDetail?()=>toggleA(i):undefined}>
          {hasDetail && <span className="cs-caret">{ao?'▾':'▸'}</span>}
          <span className="cs-loc">B{r.book}{r.ch!=null?'·C'+r.ch:''}</span>
          {r.slot && !sealed && <span className="cs-slot">{r.slot}</span>}
          {r.link && !sealed && E[r.link]
            ? <a className="cs-link" onClick={e=>{e.stopPropagation();go({view:'entry',id:r.link});}}>{r.name}</a>
            : <span className="cs-name">{sealed?'▓▓ sealed':r.name}</span>}
          {r.bonus && !sealed && <span className="cs-bonus">{r.bonus}</span>}
          <SrcChip src={sealed?null:r.src}/>
        </div>
        {ao && !sealed && (
          <div className="cs-armor-body">
            {r.note && <div className="cs-note">{linkify(r.note, entry, go)}</div>}
            {hasHeld && (
              <div className="cs-held">
                <div className="cs-held-h">Per-book stat contribution while held</div>
                {r.held.map((h,j)=>{
                  const hs = h.book>revealBook;
                  return (
                    <div className={'cs-held-row'+(hs?' sealed':'')} key={j}>
                      <span className="cs-loc">Book {h.book}</span>
                      {h.points && !hs && <span className={'cs-held-pts'+(/^0/.test(h.points)?' zero':'')}>{h.points}</span>}
                      <span className="cs-held-gain">{hs?'▓▓ sealed':h.gain}</span>
                      <SrcChip src={hs?null:h.src}/>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        )}
      </div>
    );
  });
}

function EntryDetail({entry, go}){
  const [level, setLevel] = window.useReaderLevel();
  const revealBook = Math.max(level||0, entry.book||1);
  const decryptedAll = level>=99;
  const D = window.DCC;
  const E = D.ENTRIES;
  const idx = D.ORDER.indexOf(entry.id);
  const prev = idx>0 ? E[D.ORDER[idx-1]] : null;
  const next = idx<D.ORDER.length-1 ? E[D.ORDER[idx+1]] : null;

  if(entry.type==='lootbox' || entry.type==='notification' || entry.type==='spell' || entry.type==='event' || entry.type==='lore')
    return <SnippetEntry entry={entry} go={go} prev={prev} next={next}/>;

  // Any element from a book later than the debut is a spoiler — if the entry
  // has one, surface the single clearance control.
  const hasSpoiler = entry.mentions.some(m=>(m.book||entry.book)>revealBook)
    || entry.timeline.some(n=>n.book>revealBook)
    || (entry.formLog||[]).some(r=>r.b>revealBook)
    || (entry.futureRefs.length>0 && !decryptedAll)
    || entry.stats.some(s=>statMaxBook(s[1])>revealBook)
    || sheetHasSpoiler(entry.sheet, revealBook)
    || D.allEntries().some(b=> b.type==='lootbox' && (b.awards||[]).some(a=> a.to && a.to.indexOf(entry.id)>=0 && a.b>revealBook));

  return (
    <div className="view">
      <div className="wrap">
        <div className="crumbs">
          <a onClick={()=>go({view:'books'})}>Books</a><span className="sep">/</span>
          <a onClick={()=>go({view:'book', book:entry.book})}>Book {entry.book}</a><span className="sep">/</span>
          <a onClick={()=>go({view:'chapter', book:entry.book, ch:entry.chapter})}>Ch. {entry.chapter}</a><span className="sep">/</span>
          <span style={{color:'var(--ink)'}}>{entry.name}</span>
        </div>

        <div className="entry-hero">
          <div>
            <TypeTag type={entry.type}/>
            <h1 className="eh-name">{entry.name}</h1>
            <div className="eh-sub">{entry.sub}</div>
            <DupNameNote entry={entry}/>
          </div>
          <div className="first-stamp">First logged<b>BOOK {entry.book||1} · CHAPTER {entry.chapter}</b></div>
        </div>

        <div className="detail-grid">
          <ArtStack entry={entry} go={go}/>
          <div className="detail-info">
            <p className="desc">{linkify(entry.desc, entry, go)}</p>
            <StatRow entry={entry}/>
            <InfoBox entry={entry} revealBook={revealBook}/>
          </div>
        </div>

        {entry.sheet && <CharacterSheet entry={entry} revealBook={revealBook} go={go}/>}

        {hasSpoiler && <DecryptBar entry={entry} level={level} setLevel={setLevel} revealBook={revealBook}/>}

        {entry.timeline.length>=2 && (
          <div className="section" style={{paddingBottom:0}}>
            <div className="block-h">Appearance map</div>
            <Timeline entry={entry} revealBook={revealBook}/>
          </div>
        )}

        <BoxLedger entry={entry} revealBook={revealBook} go={go}/>

        <MajorEvents entry={entry} revealBook={revealBook} go={go}/>

        <FormLog entry={entry} revealBook={revealBook}/>

        {entry.futureRefs.length>0 && (
          <RedactionSpoiler entry={entry} decrypted={decryptedAll} go={go}/>
        )}

        {entry.related && entry.related.length>0 && (
          <div className="section" style={{paddingBottom:0}}>
            <div className="block-h">Related entries</div>
            <div style={{display:'flex',flexWrap:'wrap',gap:'10px'}}>
              {entry.related.map(id=> E[id] ? (
                <div key={id} className="mention" style={{cursor:'pointer',flex:'0 0 auto'}} onClick={()=>go({view:'entry', id})}>
                  <TypeTag type={E[id].type} outline/><span style={{fontFamily:'var(--f-gothic)',fontWeight:700,fontSize:'17px'}}>{E[id].name}</span>
                </div>
              ) : null)}
            </div>
          </div>
        )}

        <div className="entry-nav">
          {prev ? <a onClick={()=>go({view:'entry', id:prev.id})}><div className="lab">◂ Previous entry</div><div className="nm">{prev.name}</div></a> : <span style={{flex:'1 1 240px'}}/>}
          {next ? <a className="nx" onClick={()=>go({view:'entry', id:next.id})}><div className="lab">Next entry ▸</div><div className="nm">{next.name}</div></a> : <span style={{flex:'1 1 240px'}}/>}
        </div>
      </div>
    </div>
  );
}

window.EntryDetail = EntryDetail;
window.TypeTag = TypeTag;
window.ArtThumb = ArtThumb;
window.SnipThumb = SnipThumb;
window.useArtImage = useArtImage;
