/* global React */
const { useState: useStateAr, useMemo: useMemoAr, useEffect: useEffectAr } = React;

// ====================================================================
// THE HEARTH ARCHIPELAGO — rebuilt for the T2 keyed band.
// (HANDOFF_BUILDINGS.md · practical-numbers §8 · wiki/rescues · facilities)
//
// The T2 content set: 11 rescued citizens, each mapping 1:1 to a building.
// Organised into the 4 keyed zones (Fire · Earth · Water · Nature). Every
// plot is a rescue→building: while unclaimed it runs as a BASE role
// (forage / offering / scout); claimed, it becomes the BUILDING. The Ice
// capstone (N2) is HIDDEN until W3 (evolution-info) is banked.
//
// Numbers are NOT inlined — they live in the T2 table below (the set is the
// data, per the handoff). Effect numbers are first-pass stubs.
// ====================================================================

const EL = {
  fire:   { name: 'Fire',   color: 'var(--el-fire)',  glyph: '△', primal: 'Ifrit'     },
  earth:  { name: 'Earth',  color: 'var(--el-earth)', glyph: '□', primal: 'Titan'     },
  water:  { name: 'Water',  color: 'var(--el-water)', glyph: '~', primal: 'Leviathan' },
  nature: { name: 'Nature', color: '#5d7a3f',         glyph: '✿', primal: 'Lakshmi'   },
  ice:    { name: 'Ice',    color: '#5a7d9a',         glyph: '◊', primal: 'Shiva'      },
};

const CAT = {
  'harness-hull':   'Harness · Hull',
  'shop':           'Shop',
  'restaurant':     'Restaurant',
  'info':           'Info Hall',
  'hint':           'Primal Hint',
  'element-unlock': 'Element Unlock',
};

// Base identity a plot runs as while its claim is unmet (wiki/rescues "no
// dead nodes"). Keyed by element domain (practical-numbers §9 forage shares).
const BASE = {
  fire:   { role: 'Ember Offering',  line: 'Unclaimed, it runs as an ember node — a burn-offering gather.' },
  earth:  { role: 'Quarry Offering', line: 'Unclaimed, it runs as a quarry — a stone-offering gather.' },
  water:  { role: 'Scout Post',      line: 'Unclaimed, it runs as a scout post — reveal downstream, no haul.' },
  nature: { role: 'Forage',          line: 'Unclaimed, it runs as forage — staple to keep the run fed.' },
};

const KINDNOTE = {
  convince: 'Requirement gate · survives clock-out · no Hull bypass.',
  physical: 'Carried home — depth = return cost; Earth eases the haul.',
};

// The 4 keyed zones of the band (practical-numbers §9 · wiki/territories §T2).
// Two PURE zones (Earth · Nature — the hubs, two home-slots each) and two
// BLEND zones (Fire/Earth · Nature/Water — the spokes Fire & Water live in the
// blends). Each zone locks out off-element progression (the variety gate) and
// holds ≥2 capstones: in blends, one per element lane. `key` = element(s) the
// zone reads; `tod` = its home time-of-day; `region` = the map area it owns.
const ZONES = {
  'z-earth': {
    name: 'The Ballast Steeps', el: 'earth', kind: 'pure',  key: 'Earth',
    tod: 'Morning', character: 'The quarry haul',
    region: { x: 4, y: 6, w: 40, h: 40 },
    capstones: ['E1', 'VAULT'], deepRecruit: 'E4',
  },
  'z-nature': {
    name: 'The Verdant Reach', el: 'nature', kind: 'pure', key: 'Nature',
    tod: 'Evening', character: 'The lush gateway',
    region: { x: 34, y: 48, w: 40, h: 48 },
    capstones: ['N2', 'GRV'], deepRecruit: 'N3',
  },
  'z-fire-earth': {
    name: 'The Emberworks', el: 'fire', kind: 'blend', key: 'Fire · Earth',
    tod: 'Morning', character: 'Dual lanes — Fire claims, Earth hauls',
    region: { x: 62, y: 46, w: 36, h: 50 },
    capstones: ['F2', 'EQ'], deepRecruit: 'F4',
  },
  'z-nature-water': {
    name: 'The Tide Stair', el: 'water', kind: 'blend', key: 'Nature · Water',
    tod: 'Evening', character: 'Navigate + sustain',
    region: { x: 62, y: 4, w: 36, h: 40 },
    capstones: ['W3', 'GRV2'], deepRecruit: 'W4',
  },
};

// ====================================================================
// THE SET — 11 rescues → 11 buildings. (sim_content_t2.py `R`)
//   gate: convince = [req, req] (min=max) · physical = [min, max] band
//   wave: maturity wave that brings a keyed specialist to ≥80% on the gate
// ====================================================================
const T2 = [
  { id:'F1', el:'fire',   kind:'physical', gate:[12,45], cat:'harness-hull', zone:'z-fire-earth',
    name:'The Cinder Forge',   role:'Harness Tuning',
    effect:'Forge — tunes the harness: +8 to a player-chosen element’s checks. (first-pass)',
    prereqs:[], wave:'typ Lesser', x:72, y:54 },
  { id:'E1', el:'earth',  kind:'physical', gate:[10,40], cat:'harness-hull', zone:'z-earth', cap:true,
    name:'The Keelstone Yard', role:'Hull Masonry',
    effect:'Mason — raises the Hull pool by +8 (max harness integrity ↑). Earth-lane capstone (carry home).',
    prereqs:[], wave:'typ Lesser', x:22, y:13 },
  { id:'W1', el:'water',  kind:'convince', gate:[18,18], cat:'info', zone:'z-nature-water',
    name:'The Sounding Glass',  role:'Node Scrying',
    effect:'Explo-info — reveals a node’s contents up to ≤3 hops ahead before you commit.',
    prereqs:[], wave:'fresh Lesser', x:74, y:24 },
  { id:'N1', el:'nature', kind:'convince', gate:[12,12], cat:'restaurant', zone:'z-nature',
    name:'The Duskleaf Table',  role:'Evening House', tod:'Evening',
    effect:'Opens Evenings — sells ~2 Water / Nature directional offerings per day @ 3 coin.',
    prereqs:[], wave:'fresh Lesser', x:46, y:54 },
  { id:'F2', el:'fire',   kind:'physical', gate:[40,90], cat:'shop', zone:'z-fire-earth', cap:true,
    name:'The Slagmarket',      role:'Smithy & Exchange',
    effect:'Smithy — trade haul goods → coin (~1:1) + gear; the T2 currency sink. Fire-lane capstone.',
    prereqs:['F1'], wave:'raised Lesser', x:88, y:66 },
  { id:'E2', el:'earth',  kind:'convince', gate:[45,45], cat:'restaurant', zone:'z-earth',
    name:'The Dawnstone Board', role:'Morning House', tod:'Morning',
    effect:'Opens Mornings — sells ~2 Fire / Earth directional offerings per day @ 3 coin.',
    prereqs:['E1'], wave:'typ Lesser', x:34, y:24 },
  { id:'W2', el:'water',  kind:'convince', gate:[60,60], cat:'info', zone:'z-nature-water',
    name:'The Wayfarer’s Chart',role:'Route Charting',
    effect:'Explo-unlock — opens +1 fresh route / arc in the keyed band.',
    prereqs:['W1'], wave:'raised Lesser', x:84, y:12 },
  { id:'F3', el:'fire',   kind:'convince', gate:[30,30], cat:'hint', zone:'z-fire-earth',
    name:'The Pyre Shrine',     role:'Primal Hint · Ifrit',
    effect:'Ifrit trigger-band hint. Deferred — 0 in-tier effect; payoff lands past T2.',
    prereqs:[], wave:'typ Lesser', x:70, y:74 },
  { id:'E3', el:'earth',  kind:'convince', gate:[30,30], cat:'hint', zone:'z-earth',
    name:'The Titan Cairn',     role:'Primal Hint · Titan',
    effect:'Titan trigger-band hint. Deferred — 0 in-tier effect; payoff lands past T2.',
    prereqs:[], wave:'typ Lesser', x:14, y:30 },
  { id:'W3', el:'water',  kind:'convince', gate:[90,90], cat:'info', zone:'z-nature-water', cap:true,
    name:'The Augur’s Lens',    role:'Evolution Augury',
    effect:'Evo-info — reveals the R2 ascension thresholds (49 / 121) and charts the hidden Ice plot. Water-lane capstone.',
    prereqs:['W2'], wave:'raised Lesser', x:94, y:33 },
  { id:'N2', el:'nature', kind:'convince', gate:[110,110], cat:'element-unlock', zone:'z-nature', cap:true,
    name:'The Hoar Hall',       role:'Ice Unlock',
    effect:'Unlocks Ice — flips Nature’s wasted leak into a live blend; opens the next drop. Element gate is necessary, not sufficient (capstone NYI).',
    prereqs:['W3','N1'], discovery:'hidden', revealedBy:'W3', wave:'maxed Lesser', x:55, y:85 },
];

// prereq edges for the DAG leaders, + the discovery edge (W3 reveals N2)
const EDGES = [
  ['F1','F2'], ['E1','E2'], ['W1','W2'], ['W2','W3'], ['N1','N2'], ['W3','N2'],
];

// ====================================================================
// EXPLORATION TIER — the zone tails (sim_t2_graphs.ZONES). NOT buildings:
// each zone carries, beyond its rescues, a deep-hazard RECRUIT and a second
// CAPSTONE (a loot/shard hoard). These sit on single-thread guarded tails
// (…→ hazard-real → recruit → capstone). Shown on the atlas so the keyed
// band's structure reads, but they don't enter the 1:1 rescue→building set.
//
//   • NYI loot capstones (VAULT·GRV·EQ·GRV2): reachable-but-walled — the
//     element gate is necessary, not sufficient (needs the NYI hub upgrade).
//   • Deep recruits (E4·N3·F4·W4): one per zone behind a deep hazard. A
//     raised keyed build force-wins the hazard at 0 Hull and banks them;
//     under-raised can't force it (Sol3 NYI) and STRANDS, losing that recruit.
// ====================================================================
const ZONE_CAPS = [
  { id:'VAULT', zone:'z-earth',        el:'earth',  gate:120, base:'forage-rich',
    name:'The Ore Vault',   note:'A sealed ore hoard on the quarry’s deepest tail.', x:39, y:43 },
  { id:'GRV',   zone:'z-nature',       el:'nature', gate:95,  base:'forage-rich',
    name:'Lakshmi’s Grove', note:'A primal grove past the gateway’s far hazard.',   x:67, y:90 },
  { id:'EQ',    zone:'z-fire-earth',   el:'earth',  gate:120, base:'forage-rich',
    name:'The Earth Core',  note:'The Earth-lane hoard — the haul lane’s payoff.',  x:93, y:83 },
  { id:'GRV2',  zone:'z-nature-water', el:'nature', gate:110, base:'forage-rich',
    name:'The Deep Grove',  note:'The Nature-lane hoard — the sustain lane’s end.', x:90, y:43 },
];
const DEEP_RECRUITS = [
  { id:'E4', zone:'z-earth',        el:'earth',  gate:60, base:'offering',
    name:'The Deep Haul-Hand', hazard:'a deep quarry hazard', x:7,  y:42 },
  { id:'N3', zone:'z-nature',       el:'nature', gate:70, base:'forage',
    name:'The Deep Larder',    hazard:'a deep grove hazard',  x:39, y:71 },
  { id:'F4', zone:'z-fire-earth',   el:'fire',   gate:60, base:'offering',
    name:'The Deep Forge-Hand',hazard:'a deep ember hazard',  x:62, y:89 },
  { id:'W4', zone:'z-nature-water', el:'water',  gate:70, base:'scout',
    name:'The Deep Pilot',     hazard:'a deep ford hazard',   x:65, y:41 },
];
// Base-role lines (wiki/rescues "no dead nodes") for the exploration tier.
const BASE_LINE = {
  'forage-rich': 'Until claimed it runs as a rich forage corner.',
  offering:      'Until claimed it runs as an offering node.',
  forage:        'Until claimed it runs as forage — staple for the run.',
  scout:         'Until claimed it runs as a scout post — reveal, no haul.',
};
const explorById = (id) => [...ZONE_CAPS, ...DEEP_RECRUITS].find(n => n.id === id);

const byId = (id) => T2.find(b => b.id === id);

function gateString(b) {
  if (b.kind === 'convince') return `${EL[b.el].name} ≥ ${b.gate[0]}`;
  return `Band ${b.gate[0]}–${b.gate[1]}`;
}

// ── Maturity waves (HANDOFF_BUILDINGS §8) ──────────────────────────────────
// A plot's `wave` is the ship-maturity at which a keyed specialist clears its
// gate (≥80%). A plot can't be RAISED until the roster reaches that wave — the
// progression gate the map must honour, on top of the prereq DAG.
const WAVES = ['fresh', 'typ', 'raised', 'maxed'];
const WAVE_LABEL = { fresh: 'fresh Lesser', typ: 'typical Lesser', raised: 'raised Lesser', maxed: 'maxed Lesser' };
const waveIx = (b) => Math.max(0, WAVES.indexOf((b.wave || '').split(' ')[0]));
// Capstones (element-unlock) need an NYI consumable/hub upgrade — gate is
// "necessary, not sufficient": reachable-but-unclaimable until HAS_NYI.
const isCapstone = (b) => b.cat === 'element-unlock';
const HAS_NYI = false;

// Everything STRICTLY below the current maturity frontier is already banked
// (respecting the prereq DAG + charting). The NYI capstone never auto-banks.
// Iterates to a fixpoint so prereq chains resolve in order.
function autoBank(maturityIx) {
  const banked = new Set();
  let changed = true;
  while (changed) {
    changed = false;
    for (const b of T2) {
      if (banked.has(b.id) || isCapstone(b)) continue;
      if (waveIx(b) >= maturityIx) continue;          // only earlier waves are already-raised
      const revealed = !b.discovery || b.discovery !== 'hidden' || banked.has(b.revealedBy);
      if (!revealed) continue;
      if (!b.prereqs.every(p => banked.has(p))) continue;
      banked.add(b.id); changed = true;
    }
  }
  return banked;
}

// Build the enriched `view` a card renders, given the claimed-set + roster maturity.
function buildView(b, claimed, maturityIx, bandOpen = true) {
  const revealed = !b.discovery || b.discovery !== 'hidden' || claimed.has(b.revealedBy);
  const prereqsMet = b.prereqs.every(p => claimed.has(p));
  const matureEnough = waveIx(b) <= maturityIx;
  let status, lockReason = null;
  if (claimed.has(b.id)) status = 'built';
  else if (!revealed) status = 'hidden';
  else if (!bandOpen) { status = 'locked'; lockReason = 'band'; }   // band not yet charted
  else if (!prereqsMet) { status = 'locked'; lockReason = 'prereq'; }
  else if (!matureEnough) { status = 'locked'; lockReason = 'maturity'; }
  else if (isCapstone(b) && !HAS_NYI) { status = 'ready'; lockReason = 'nyi'; }
  else status = 'ready';

  return {
    id: b.id, name: b.name, role: b.tod ? `${b.role} · ${b.tod}` : b.role,
    effect: b.effect, zoneName: ZONES[b.zone].name,
    element: { key: b.el, ...EL[b.el] },
    category: { key: b.cat, label: CAT[b.cat] },
    kind: b.kind, gate: gateString(b), gateNote: KINDNOTE[b.kind],
    prereqs: b.prereqs.map(p => ({ id: p, name: byId(p).name, met: claimed.has(p) })),
    base: { role: BASE[b.el].role, label: BASE[b.el].role + ' node', line: BASE[b.el].line },
    wave: b.wave, waveLabel: WAVE_LABEL[(b.wave || '').split(' ')[0]] || b.wave,
    discovery: b.discovery || 'visible', status, lockReason,
  };
}

// Tweakable demo state — scrub the roster from cold start to endgame.
const MAP_TWEAKS = /*EDITMODE-BEGIN*/{
  "maturity": "fresh",
  "preview": false
}/*EDITMODE-END*/;

const CARD_STYLES = [
  { key: 'plate',    label: 'Survey Plate',   comp: 'FacCardPlate'    },
  { key: 'specimen', label: 'Specimen Folio', comp: 'FacCardSpecimen' },
  { key: 'manifest', label: 'Cargo Manifest', comp: 'FacCardManifest' },
];

// ====================================================================
// CityMap — the archipelago screen (exported under the name App expects).
// ====================================================================
function CityMap({ state, dispatch, onBack }) {
  // Roster maturity drives availability (Tweaks-scrubbable). Everything below the
  // frontier is auto-banked; hand-claims layer on top for the current wave.
  const [tw, setTweak] = useTweaks(MAP_TWEAKS);
  const maturityIx = Math.max(0, WAVES.indexOf(tw.maturity));
  // The keyed band only opens once Zone 0's Gateway crew is rescued (state.
  // keyedZonesUnlocked, set by end3). Before that NOTHING here is reachable —
  // the maturity scrubber is a design preview and must not bypass the unlock.
  // `preview` (Tweaks) force-opens the band for design review only.
  const bandOpen = !!state.keyedZonesUnlocked || !!tw.preview;
  const [manual, setManual] = useStateAr(() => new Set());
  // Claimed-set source (default flip — questions timed out):
  //   • PREVIEW (Tweaks `preview` on) → the maturity scrubber design tool:
  //     autoBank everything below the frontier + hand-claims (no real play needed).
  //   • REAL PLAY → claimed = the buildings actually RECRUITED+RAISED from keyed
  //     runs (state.built, set by RETURN_PORT → bankRun). The scrubber is inert here.
  const claimed = useMemoAr(() => {
    if (!bandOpen) return new Set();
    if (tw.preview) { const b = autoBank(maturityIx); manual.forEach(id => b.add(id)); return b; }
    const b = new Set(Object.keys(state.built || {}));
    manual.forEach(id => b.add(id));
    return b;
  }, [maturityIx, manual, bandOpen, tw.preview, state.built]);
  const bankedSet = useMemoAr(() => new Set(Object.keys(state.banked || {})), [state.banked]);
  const [selected, setSelected] = useStateAr('W1');   // open on a fresh-wave ready plot
  const [cardStyle, setCardStyle] = useStateAr(() => {
    try { return localStorage.getItem('sh-faccard') || 'plate'; } catch (e) { return 'plate'; }
  });
  useEffectAr(() => { try { localStorage.setItem('sh-faccard', cardStyle); } catch (e) {} }, [cardStyle]);

  const views = useMemoAr(() => {
    const m = {}; T2.forEach(b => { m[b.id] = buildView(b, claimed, maturityIx, bandOpen); }); return m;
  }, [claimed, maturityIx, bandOpen]);

  // Selection may be a BUILDING (1:1 rescue) or an EXPLORATION node (a zone's
  // deep recruit / loot capstone). The panel swaps card type accordingly.
  const explore = explorById(selected);
  const isExplore = !!explore;
  const sel = isExplore ? explore : (byId(selected) || T2[0]);
  const selView = isExplore ? null : views[selected];

  const counts = useMemoAr(() => {
    let built = 0, ready = 0, sealed = 0, hidden = 0;
    T2.forEach(b => { const s = views[b.id].status;
      if (s === 'built') built++; else if (s === 'ready') ready++;
      else if (s === 'locked') sealed++; else hidden++; });
    return { built, ready, sealed, hidden };
  }, [views]);

  function claim(id) {
    if (!bandOpen) return;                    // band must be charted first (end3)
    const b = byId(id);
    if (isCapstone(b) && !HAS_NYI) return;   // capstone gate is necessary-not-sufficient (NYI)
    setManual(prev => { const n = new Set(prev); n.add(id); return n; });
  }

  const CardComp = window[CARD_STYLES.find(c => c.key === cardStyle).comp] || window.FacCardPlate;

  return (
    <div className="canvas-content" style={{ padding: '22px 30px', display: 'flex', flexDirection: 'column', gap: 12 }}>
      {/* HEADER */}
      <header style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 24, alignItems: 'center', paddingRight: 104 }}>
        <div className="col gap-4">
          <div className="label">Atlas · Folio II · The Keyed Band</div>
          <div className="fell" style={{ fontSize: 23, lineHeight: 1.05 }}>The Hearth Archipelago</div>
          <div className="row gap-8" style={{ fontSize: 11, color: 'var(--sepia)', marginTop: 2, flexWrap: 'wrap' }}>
            <span className="elite">Raised</span>
            <span className="mono">{counts.built} / {T2.length}</span>
            <span style={{ opacity: 0.4 }}>·</span>
            <span className="elite">Ready</span><span className="mono">{counts.ready}</span>
            <span style={{ opacity: 0.4 }}>·</span>
            <span className="elite">Sealed</span><span className="mono">{counts.sealed}</span>
            {counts.hidden > 0 && <><span style={{ opacity: 0.4 }}>·</span>
              <span className="elite" style={{ color: 'var(--ink-faded)' }}>Uncharted</span>
              <span className="mono" style={{ color: 'var(--ink-faded)' }}>{counts.hidden}</span></>}
          </div>
        </div>
        <div className="col" style={{ alignItems: 'center', gap: 6 }}>
          <span className={`stamp ${bandOpen ? 'stamp-blue' : ''} tilt-l`}
            style={bandOpen ? {} : { borderColor: 'var(--crit)', color: 'var(--crit)' }}>
            {bandOpen ? 'RESCUE = BUILDING · 1:1' : 'BAND SEALED · CHART ZONE 0'}
          </span>
          {/* Real, findable entry into the keyed band (the Launch picker reads
              state.keyedZonesUnlocked). In play it opens by clearing Z0's end3
              gateway; this is the review bridge past that ≥100 grind. */}
          {!state.keyedZonesUnlocked ? (
            <button className="btn" onClick={() => dispatch({ type: 'UNLOCK_KEYED_REVIEW' })}
              style={{ padding: '4px 11px', fontSize: 10, background: 'var(--ink)', color: 'var(--paper-light)', letterSpacing: '0.04em' }}>
              ◈ Open keyed band — review ▸
            </button>
          ) : (
            <button className="btn" onClick={() => dispatch({ type: 'LAUNCH' })}
              disabled={state.rank < 1}
              style={{ padding: '4px 11px', fontSize: 10, background: state.rank < 1 ? 'transparent' : 'var(--ink)', color: state.rank < 1 ? 'var(--ink-faded)' : 'var(--paper-light)', letterSpacing: '0.04em' }}>
              ▸ Launch a keyed expedition{state.rank < 1 ? ' (raise to R1 first)' : ''}
            </button>
          )}
        </div>
        <div className="col gap-4" style={{ alignItems: 'flex-end' }}>
          <div className="label">Hearth Treasury</div>
          <div className="row gap-12">
            <span className="row gap-4" style={{ alignItems: 'baseline' }}>
              <span className="fell" style={{ fontSize: 24, lineHeight: 1 }}>{state.manifestDays}</span>
              <span className="label">DAYS</span>
            </span>
            <span style={{ opacity: 0.4 }}>·</span>
            <span className="row gap-4" style={{ alignItems: 'baseline' }}>
              <span className="fell" style={{ fontSize: 24, lineHeight: 1 }}>{state.town.pop}</span>
              <span className="label">SOULS</span>
            </span>
          </div>
        </div>
      </header>

      <hr className="divider-h" style={{ borderColor: 'var(--ink)', margin: 0 }} />

      {/* MAIN — map + facility card */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 14, flex: 1, minHeight: 0 }}>

        {/* MAP */}
        <div className="panel brackets" style={{ position: 'relative', overflow: 'hidden' }}>
          <div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(assets/archipelago.jpg)',
            backgroundSize: 'cover', backgroundPosition: 'center',
            filter: 'sepia(0.16) saturate(0.86) brightness(0.99) contrast(0.97)' }} />
          <div style={{ position: 'absolute', inset: 0, background: 'rgba(236,225,199,0.30)', mixBlendMode: 'multiply', pointerEvents: 'none' }} />
          <div style={{ position: 'absolute', inset: 0,
            background: 'radial-gradient(ellipse at center, transparent 52%, rgba(196,168,110,0.22) 100%)',
            mixBlendMode: 'multiply', pointerEvents: 'none' }} />

          {/* graticule + DAG leaders */}
          <svg viewBox="0 0 1000 600" preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>
            <defs>
              <marker id="ar-arrow" markerWidth="7" markerHeight="7" refX="5.5" refY="3" orient="auto">
                <path d="M0,0 L6,3 L0,6 Z" fill="var(--ink-faded)" />
              </marker>
            </defs>
            {[0.16, 0.32, 0.48, 0.64, 0.80].map((y, i) => (
              <line key={'la' + i} x1="0" y1={y * 600} x2="1000" y2={y * 600} stroke="var(--ink)" strokeOpacity="0.06" strokeWidth="1" strokeDasharray="6 8" />
            ))}
            {[0.12, 0.36, 0.60, 0.84].map((x, i) => (
              <line key={'lo' + i} x1={x * 1000} y1="0" x2={x * 1000} y2="600" stroke="var(--ink)" strokeOpacity="0.06" strokeWidth="1" strokeDasharray="6 8" />
            ))}
            {/* prereq leaders */}
            {EDGES.map(([a, c], i) => {
              const A = byId(a), B = byId(c);
              const va = views[a].status, vb = views[c].status;
              if (vb === 'hidden') return null;     // don't draw into an uncharted plot
              const lit = va === 'built';
              return (
                <line key={'e' + i}
                  x1={A.x * 10} y1={A.y * 6} x2={B.x * 10} y2={B.y * 6}
                  stroke={lit ? EL[A.el].color : 'var(--ink-faded)'} strokeOpacity={lit ? 0.7 : 0.4}
                  strokeWidth={lit ? 1.6 : 1.2} strokeDasharray={lit ? 'none' : '4 5'}
                  markerEnd="url(#ar-arrow)" />
              );
            })}
          </svg>

          {/* zone regions — the 4 keyed zones, each its key + home ToD */}
          {Object.entries(ZONES).map(([zid, z]) => (
            <ZoneRegion key={zid} z={z}
              highlit={ZONES[sel.zone] === z} />
          ))}

          {/* compass */}
          <div style={{ position: 'absolute', top: 14, right: 16, width: 64, height: 64, opacity: 0.85, pointerEvents: 'none' }}>
            <svg viewBox="0 0 60 60" style={{ width: '100%', height: '100%' }}>
              <circle cx="30" cy="30" r="26" fill="rgba(244,236,214,0.7)" stroke="var(--ink)" strokeWidth="0.8" />
              <circle cx="30" cy="30" r="20" fill="none" stroke="var(--ink)" strokeWidth="0.4" strokeDasharray="1 2" />
              <path d="M30 6 L33 28 L30 30 L27 28 Z" fill="var(--stamp-amber)" fillOpacity="0.7" stroke="var(--ink)" strokeWidth="0.5" />
              <path d="M30 54 L33 32 L30 30 L27 32 Z" fill="var(--ink)" fillOpacity="0.5" />
              <text x="30" y="4" textAnchor="middle" fontSize="6" fill="var(--ink)" fontFamily="IM Fell English SC, serif">N</text>
            </svg>
          </div>

          {/* folio tag */}
          <div style={{ position: 'absolute', bottom: 14, right: 16, background: 'rgba(244,236,214,0.75)',
            padding: '4px 12px', fontFamily: 'IM Fell English SC, serif', fontSize: 11, letterSpacing: '0.18em',
            color: 'var(--ink)', border: '1px solid var(--ink-faded)', transform: 'rotate(-1deg)' }}>
            DRAFTED · ATLAS VII · THE KEYED BAND
          </div>

          {/* plot markers — the 11 rescue→buildings */}
          {T2.map(b => (
            <PlotMarker key={b.id} b={b} view={views[b.id]} selected={selected === b.id} onClick={() => setSelected(b.id)} />
          ))}

          {/* exploration tier — deep recruits + loot capstones (not buildings) */}
          {DEEP_RECRUITS.map(n => (
            <ExploreMarker key={n.id} n={n} kind="recruit" banked={bankedSet.has(n.id)} selected={selected === n.id} onClick={() => setSelected(n.id)} />
          ))}
          {ZONE_CAPS.map(n => (
            <ExploreMarker key={n.id} n={n} kind="capstone" selected={selected === n.id} onClick={() => setSelected(n.id)} />
          ))}

          {/* band-sealed scrim — the keyed band isn't open until Z0's gateway crew is rescued */}
          {!bandOpen && (
            <div style={{ position: 'absolute', inset: 0, zIndex: 50, pointerEvents: 'none',
              background: 'rgba(28,22,14,0.30)', backdropFilter: 'grayscale(0.4)',
              display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              <div style={{ textAlign: 'center', background: 'rgba(244,236,214,0.94)',
                border: '1.5px solid var(--ink)', padding: '16px 26px', maxWidth: 440,
                boxShadow: '0 8px 28px rgba(20,14,8,0.4)', transform: 'rotate(-0.6deg)' }}>
                <span className="stamp" style={{ fontSize: 10, borderColor: 'var(--crit)', color: 'var(--crit)' }}>UNCHARTED</span>
                <div className="fell" style={{ fontSize: 22, lineHeight: 1.1, margin: '9px 0 5px' }}>The Keyed Band is Sealed</div>
                <div className="elite" style={{ fontSize: 12, color: 'var(--ink-soft)', lineHeight: 1.5 }}>
                  No plot here can be raised yet. Rescue the <span className="fell">Gateway crew</span> at the
                  far edge of Zone 0 to open the four keyed zones.
                </div>
              </div>
            </div>
          )}
        </div>

        {/* DETAIL PANEL — facility card (building) or exploration node */}
        <div className="col" style={{ minHeight: 0, gap: 10 }}>
          {isExplore ? (
            /* exploration node header — not a 1:1 building */
            <div className="panel" style={{ padding: '8px 12px', flexShrink: 0 }}>
              <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
                <span className="label label-ink" style={{ fontSize: 9 }}>
                  {sel.id.match(/^(VAULT|GRV|EQ|GRV2)$/) ? 'Zone Tail · Loot Capstone' : 'Zone Tail · Deep Recruit'}
                </span>
                <span className="mono" style={{ fontSize: 9.5, color: 'var(--sepia)' }}>NODE · {sel.id}</span>
              </div>
            </div>
          ) : (
            /* card-style switch — the 3 directions to compare */
            <div className="panel" style={{ padding: '8px 10px', flexShrink: 0 }}>
              <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center', marginBottom: 7 }}>
                <span className="label label-ink" style={{ fontSize: 9 }}>Facility Card · Direction</span>
                <span className="mono" style={{ fontSize: 9.5, color: 'var(--sepia)' }}>PLOT · {sel.id}</span>
              </div>
              <div className="row" style={{ gap: 5 }}>
                {CARD_STYLES.map(cs => {
                  const on = cs.key === cardStyle;
                  return (
                    <button key={cs.key} onClick={() => setCardStyle(cs.key)}
                      className="elite" style={{
                        flex: 1, padding: '7px 4px', fontSize: 10.5, cursor: 'pointer',
                        border: `1.5px solid ${on ? 'var(--ink)' : 'var(--ink-faded)'}`,
                        background: on ? 'var(--ink)' : 'transparent',
                        color: on ? 'var(--paper-light)' : 'var(--ink-soft)',
                        letterSpacing: '0.04em', transition: 'all 120ms ease',
                      }}>{cs.label}</button>
                  );
                })}
              </div>
            </div>
          )}

          {/* the card */}
          <div style={{ flex: 1, overflow: 'auto', paddingRight: 2 }}>
            {isExplore
              ? <ExploreCard n={sel} zone={ZONES[sel.zone]}
                  kind={sel.id.match(/^(VAULT|GRV|EQ|GRV2)$/) ? 'capstone' : 'recruit'} />
              : <CardComp view={selView} onClaim={() => claim(sel.id)} />}
          </div>
        </div>
      </div>

      {/* FOOTER */}
      <footer className="row" style={{ padding: '12px 18px', justifyContent: 'space-between',
        border: '1.5px solid var(--ink)', background: 'rgba(244,236,214,0.4)' }}>
        <button className="btn" onClick={onBack}><span style={{ marginRight: 8 }}>◂</span>Back to Port Authority</button>
        <div className="row gap-12" style={{ alignItems: 'center', flexWrap: 'wrap' }}>
          <span className="label">LEGEND</span>
          <LegendDot color="var(--ink)" label="Raised" />
          <LegendDot color="var(--stamp-amber)" label="Ready" pulse />
          <LegendDot color="var(--sepia)" label="Sealed" dashed />
          <span style={{ opacity: 0.35 }}>·</span>
          <LegendDot color="var(--sepia)" label="Deep recruit" glyph="✚" />
          <LegendDot color="var(--sepia)" label="Loot capstone · NYI" glyph="◈" dashed />
        </div>
        <button className="btn btn-launch" onClick={() => dispatch({ type: 'GOTO', page: 'port' })}>To Training Floor ▸</button>
      </footer>

      {/* Portaled to <body> so the panel escapes the map screen's display:none
          wrapper and stays visible from any screen the user toggles Tweaks on. */}
      {ReactDOM.createPortal(
        <TweaksPanel title="Tweaks">
          <TweakSection label="Progression" />
          <TweakToggle label="Preview: band unlocked"
            value={!!tw.preview}
            onChange={(v) => { setTweak('preview', v); if (!v) setManual(new Set()); }} />
          <div style={{ fontSize: 10.5, color: 'rgba(41,38,27,.55)', lineHeight: 1.45, marginBottom: 4 }}>
            Design preview only. In play the band opens when Zone 0’s Gateway crew is rescued
            (<span className="mono">keyedZonesUnlocked</span>) — until then every plot is sealed.
          </div>
          <TweakRow label="Roster maturity" value={WAVE_LABEL[tw.maturity]}>
            <input type="range" className="twk-slider" min={0} max={WAVES.length - 1} step={1}
              value={maturityIx} disabled={!bandOpen}
              onChange={(e) => { setTweak('maturity', WAVES[Number(e.target.value)]); setManual(new Set()); }} />
          </TweakRow>
          <div style={{ fontSize: 10.5, color: 'rgba(41,38,27,.55)', lineHeight: 1.45 }}>
            Once open, scrub the roster from a cold start to endgame — plots raise wave by wave as the
            Lesser matures. The Ice capstone stays walled (necessary, not sufficient).
          </div>
          {manual.size > 0 &&
            <TweakButton label="Reset hand-claims" secondary onClick={() => setManual(new Set())} />}
        </TweaksPanel>,
        document.body
      )}
    </div>
  );
}

// ====================================================================
// Plot marker
// ====================================================================
function PlotMarker({ b, view, selected, onClick }) {
  const el = EL[b.el];
  const st = view.status;
  const built = st === 'built', ready = st === 'ready', hidden = st === 'hidden', locked = st === 'locked';
  // Uncharted plots are fully invisible — no marker, no tag, no footprint.
  if (hidden) return null;
  const color = hidden ? 'var(--ink-faded)' : el.color;
  const [hover, setHover] = useStateAr(false);

  // hovered marker floats above all neighbours so its name-tag is never
  // covered (and therefore always clickable); selected sits just below.
  const zIndex = hover ? 40 : selected ? 26 : 12;

  return (
    <div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      style={{ position: 'absolute', left: `${b.x}%`, top: `${b.y}%`,
      transform: 'translate(-50%, -50%)', cursor: 'pointer', zIndex }}>
      {selected && <div style={{ position: 'absolute', inset: -8, border: '1.5px solid var(--ink)', borderRadius: '50%', opacity: 0.7 }} />}

      <div style={{
        width: 34, height: 34, borderRadius: '50%',
        background: built
          ? 'radial-gradient(circle, rgba(244,236,214,0.78) 38%, rgba(244,236,214,0.12) 78%, transparent 100%)'
          : 'radial-gradient(circle, rgba(244,236,214,0.55) 30%, transparent 72%)',
        border: `${built ? 1.5 : 1.25}px ${(locked || hidden) ? 'dashed' : 'solid'} ${color}`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'IM Fell English SC, serif', fontSize: 17, color,
        textShadow: '0 1px 2px rgba(244,236,214,0.95), 0 0 3px rgba(244,236,214,0.85)',
        filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.28))',
        animation: ready ? 'marker-pulse 1.8s ease-in-out infinite' : 'none', position: 'relative',
      }}>
        {hidden ? '?' : el.glyph}
        {built && <>
          <span style={{ position: 'absolute', top: -3, left: -3, width: 6, height: 6, borderTop: '1.25px solid var(--ink)', borderLeft: '1.25px solid var(--ink)' }} />
          <span style={{ position: 'absolute', bottom: -3, right: -3, width: 6, height: 6, borderBottom: '1.25px solid var(--ink)', borderRight: '1.25px solid var(--ink)' }} />
        </>}
      </div>

      <div onClick={onClick} style={{ position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
        background: (hover || selected) ? 'rgba(244,236,214,0.97)' : 'rgba(244,236,214,0.82)', padding: '2px 7px', cursor: 'pointer',
        border: `1px solid ${(hover || selected) ? 'var(--ink)' : (locked || hidden) ? 'var(--ink-faded)' : color}`,
        boxShadow: hover ? '0 2px 5px rgba(0,0,0,0.22)' : 'none', whiteSpace: 'nowrap', transition: 'background 100ms, box-shadow 100ms' }}>
        <div className="fell" style={{ fontSize: 10, color: (locked || hidden) ? 'var(--ink-faded)' : 'var(--ink)', textAlign: 'center', letterSpacing: '0.03em' }}>
          <span className="mono" style={{ fontSize: 7.5, color: 'var(--sepia)', letterSpacing: '0.05em', marginRight: 4 }}>{b.id}</span>
          {hidden ? '— ? —' : b.name.replace(/^The /, '')}
        </div>
      </div>

      {ready && <div style={{ position: 'absolute', top: -9, right: -13, transform: 'rotate(8deg)' }}>
        <span className="stamp stamp-amber" style={{ fontSize: 6.5, padding: '1px 3px' }}>READY</span>
      </div>}
    </div>
  );
}

function LegendDot({ color, label, pulse, dashed, glyph }) {
  return (
    <div className="row gap-6" style={{ fontSize: 11, color: 'var(--sepia)' }}>
      <span style={{ width: 13, height: 13, borderRadius: '50%',
        border: `1.5px ${dashed ? 'dashed' : 'solid'} ${color}`,
        background: dashed ? 'transparent' : `${color}33`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'IM Fell English SC, serif', fontSize: 9, color,
        animation: pulse ? 'marker-pulse 1.8s ease-in-out infinite' : 'none' }}>{glyph || ''}</span>
      <span className="elite">{label}</span>
    </div>
  );
}

// ====================================================================
// Zone region overlay — translucent boundary + a key/ToD badge for one of
// the 4 keyed zones. Highlights when the selected plot belongs to it.
// ====================================================================
function ZoneRegion({ z, highlit }) {
  const c = EL[z.el].color;
  return (
    <div style={{ position: 'absolute', left: `${z.region.x}%`, top: `${z.region.y}%`,
      width: `${z.region.w}%`, height: `${z.region.h}%`, pointerEvents: 'none',
      border: `1px ${highlit ? 'solid' : 'dashed'} ${c}`, borderRadius: 9,
      background: highlit ? `${c}10` : 'transparent',
      boxShadow: highlit ? `inset 0 0 0 1px ${c}28` : 'none',
      transition: 'background 180ms ease, box-shadow 180ms ease' }}>
      <div style={{ position: 'absolute', top: 6, left: 8, display: 'flex', gap: 6, alignItems: 'baseline',
        whiteSpace: 'nowrap', flexWrap: 'nowrap',
        background: 'rgba(244,236,214,0.84)', border: `1px solid ${c}`, padding: '2px 7px' }}>
        <span className="fell" style={{ fontSize: 11, color: 'var(--ink)', lineHeight: 1 }}>{z.name.replace(/^The /, '')}</span>
        <span className="mono" style={{ fontSize: 7.5, color: c, letterSpacing: '0.05em' }}>{z.key.toUpperCase()}</span>
        <span className="mono" style={{ fontSize: 7.5, color: 'var(--sepia)' }}>· {z.tod}</span>
      </div>
    </div>
  );
}

// ====================================================================
// Exploration marker — a zone's deep recruit (✚) or loot capstone (◈).
// Dashed + muted: future content, not a 1:1 building.
// ====================================================================
function ExploreMarker({ n, kind, selected, onClick, banked }) {
  const c = EL[n.el].color;
  const [hover, setHover] = useStateAr(false);
  const isCap = kind === 'capstone';
  const zIndex = hover ? 40 : selected ? 25 : 10;
  return (
    <div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
      style={{ position: 'absolute', left: `${n.x}%`, top: `${n.y}%`,
        transform: 'translate(-50%, -50%)', cursor: 'pointer', zIndex }}>
      {selected && <div style={{ position: 'absolute', inset: -7, border: '1.5px solid var(--ink)', borderRadius: '50%', opacity: 0.7 }} />}
      <div style={{ width: 27, height: 27, borderRadius: '50%',
        background: banked ? `${c}33` : 'radial-gradient(circle, rgba(244,236,214,0.5) 28%, transparent 72%)',
        border: `1.25px ${banked ? 'solid' : 'dashed'} ${c}`, opacity: selected || hover || banked ? 1 : 0.9,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'IM Fell English SC, serif', fontSize: 13, color: c,
        textShadow: '0 1px 2px rgba(244,236,214,0.95), 0 0 3px rgba(244,236,214,0.8)',
        filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.22))' }}>
        {banked ? '✓' : isCap ? '◈' : '✚'}
      </div>
      {(hover || selected) && (
        <div style={{ position: 'absolute', top: 'calc(100% + 3px)', left: '50%', transform: 'translateX(-50%)',
          background: 'rgba(244,236,214,0.97)', padding: '1px 6px', border: `1px solid ${c}`, whiteSpace: 'nowrap' }}>
          <div className="fell" style={{ fontSize: 9.5, color: 'var(--ink)', letterSpacing: '0.02em' }}>
            <span className="mono" style={{ fontSize: 7, color: 'var(--sepia)', marginRight: 3 }}>{n.id}</span>
            {n.name.replace(/^The /, '')}
          </div>
        </div>
      )}
    </div>
  );
}

// small inked element medallion (local — the cards' ElemSeal isn't exported)
function Seal({ el, size = 46 }) {
  const c = EL[el].color;
  return (
    <div style={{ width: size, height: size, borderRadius: '50%', flexShrink: 0,
      border: `1.5px solid ${c}`, background: `${c}1c`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontFamily: 'IM Fell English SC, serif', fontSize: size * 0.46, color: c,
      boxShadow: `inset 0 0 0 3px ${c}10` }}>{EL[el].glyph}</div>
  );
}
function FacRow2({ label, value, mono, color }) {
  return (
    <div className="row" style={{ alignItems: 'baseline', padding: '1px 0' }}>
      <span className="label" style={{ fontSize: 9, whiteSpace: 'nowrap', flexShrink: 0 }}>{label}</span>
      <span style={{ flex: '1 1 auto', minWidth: 6, borderBottom: '1px dotted var(--ink-ghost)', margin: '0 7px', transform: 'translateY(-3px)' }} />
      <span className={mono ? 'mono' : 'elite'} style={{ fontSize: mono ? 11 : 12, color: color || 'var(--ink)', whiteSpace: 'nowrap', flexShrink: 0, textAlign: 'right' }}>{value}</span>
    </div>
  );
}
const BASE_LABEL = { 'forage-rich': 'Rich Forage', offering: 'Offering', forage: 'Forage', scout: 'Scout Post' };

// ====================================================================
// Exploration detail card — for a deep recruit / loot capstone (the zone
// tails). Echoes the facility plate, but no claim action: recruits bank only
// by forcing the deep hazard; capstones are walled (NYI).
// ====================================================================
function ExploreCard({ n, zone, kind }) {
  const c = EL[n.el].color;
  const isCap = kind === 'capstone';
  return (
    <div className="panel brackets" style={{ position: 'relative', background: 'rgba(244,236,214,0.62)',
      borderColor: 'var(--ink)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 5, background: c }} />
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'center',
        padding: '8px 14px 8px 18px', borderBottom: '1px solid var(--ink-faded)' }}>
        <span className="mono" style={{ fontSize: 10.5, letterSpacing: '0.12em', color: 'var(--sepia)' }}>NODE · {n.id}</span>
        <span className="label" style={{ fontSize: 9, color: c }}>{isCap ? 'Loot Capstone' : 'Deep Recruit'}</span>
      </div>
      <div style={{ padding: '14px 18px 16px' }}>
        <div style={{ marginBottom: 11 }}>
          <span className="stamp" style={{ fontSize: 10,
            borderColor: isCap ? 'var(--sepia)' : 'var(--stamp-amber)',
            color: isCap ? 'var(--sepia)' : 'var(--stamp-amber)' }}>
            {isCap ? 'WALLED · NYI' : 'BEHIND A DEEP HAZARD'}
          </span>
        </div>
        <div className="row" style={{ gap: 13, alignItems: 'flex-start', marginBottom: 12 }}>
          <Seal el={n.el} />
          <div className="col" style={{ flex: 1, minWidth: 0 }}>
            <div className="fell" style={{ fontSize: 23, lineHeight: 1.05 }}>{n.name}</div>
            <div className="label" style={{ marginTop: 3 }}>{EL[n.el].name} · {zone.name}</div>
          </div>
        </div>
        <div className="panel panel-tight" style={{ padding: '9px 12px', marginBottom: 12,
          borderColor: c, borderWidth: 1.5, background: `${c}0e` }}>
          <div className="label" style={{ fontSize: 8.5, marginBottom: 3 }}>{isCap ? 'THE PAYOFF' : 'THE RESCUE'}</div>
          <div className="elite" style={{ fontSize: 12.5, lineHeight: 1.45 }}>
            {isCap ? n.note
              : `A citizen adrift past ${n.hazard}. A raised ${EL[n.el].name} build force-wins the hazard at 0 Hull and banks them.`}
          </div>
        </div>
        <FacRow2 label="KIND" value={isCap ? 'Convince (gate)' : 'Convince (recruit)'} />
        <FacRow2 label="GATE" mono value={`${EL[n.el].name} ≥ ${n.gate}`} />
        <FacRow2 label="UNCLAIMED" value={BASE_LABEL[n.base] || n.base} color="var(--sepia)" />
        <FacRow2 label="TAIL" value={isCap ? 'single-thread · guarded' : 'hazard → recruit → cap'} />
        <div className="elite" style={{ fontSize: 10.5, color: 'var(--sepia)', lineHeight: 1.45, margin: '10px 0 0' }}>
          <span style={{ color: 'var(--ink-faded)' }}>※ </span>{BASE_LINE[n.base]}
        </div>
        <div className="row gap-8" style={{ marginTop: 12, padding: '9px 12px',
          border: `1.5px dashed ${isCap ? 'var(--sepia)' : 'var(--stamp-amber)'}`,
          background: isCap ? 'rgba(107,74,43,0.05)' : 'rgba(184,99,42,0.06)' }}>
          <span style={{ color: isCap ? 'var(--sepia)' : 'var(--stamp-amber)', fontSize: 13 }}>{isCap ? '◈' : '↟'}</span>
          <span className="elite" style={{ fontSize: 11, color: 'var(--ink-soft)', lineHeight: 1.4 }}>
            {isCap
              ? <>Reachable, but the hearth upgrade that claims it isn’t built yet. <span className="mono" style={{ fontSize: 9 }}>NYI</span></>
              : <>Under-raised, the hazard can’t be forced (Sol3 NYI) — the run strands and this recruit is lost. The maturity gate.</>}
          </span>
        </div>
      </div>
    </div>
  );
}

// ====================================================================
// Zone survey card — one of the 4 keyed zones at a glance.
// ====================================================================
function ZoneSurveyCard({ zid, z, views, active, onPick }) {
  const c = EL[z.el].color;
  const blds = T2.filter(b => b.zone === zid);
  const raised = blds.filter(b => views[b.id] && views[b.id].status === 'built').length;
  const recruit = DEEP_RECRUITS.find(n => n.zone === zid);
  return (
    <div onClick={() => onPick(recruit ? recruit.id : blds[0].id)}
      className="panel" style={{ flex: 1, padding: '8px 11px', cursor: 'pointer', position: 'relative',
        borderColor: active ? c : 'var(--ink-faded)', borderWidth: active ? 1.5 : 1,
        background: active ? `${c}0c` : 'rgba(244,236,214,0.4)', overflow: 'hidden',
        transition: 'background 140ms ease' }}>
      <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, background: c }} />
      <div className="row" style={{ justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, paddingLeft: 5 }}>
        <span className="fell" style={{ fontSize: 13.5, lineHeight: 1 }}>{z.name.replace(/^The /, '')}</span>
        <span className="mono" style={{ fontSize: 7.5, color: c, letterSpacing: '0.05em' }}>{z.kind === 'blend' ? 'BLEND' : 'PURE'}</span>
      </div>
      <div className="row gap-6" style={{ paddingLeft: 5, marginBottom: 5, alignItems: 'baseline', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
        <span className="mono" style={{ fontSize: 8.5, color: c }}>{z.key}</span>
        <span style={{ opacity: 0.4, fontSize: 8 }}>·</span>
        <span className="mono" style={{ fontSize: 8.5, color: 'var(--sepia)' }}>{z.tod}</span>
      </div>
      <div className="elite" style={{ fontSize: 9.5, color: 'var(--ink-soft)', lineHeight: 1.35, paddingLeft: 5, marginBottom: 6, minHeight: 25 }}>{z.character}</div>
      <div className="row gap-7" style={{ paddingLeft: 5, fontSize: 8.5, color: 'var(--sepia)', flexWrap: 'wrap' }}>
        <span className="mono">{raised}/{blds.length} raised</span>
        <span style={{ opacity: 0.4 }}>·</span>
        <span className="mono">{z.capstones.length} caps</span>
        <span style={{ opacity: 0.4 }}>·</span>
        <span className="mono">1 deep recruit</span>
      </div>
    </div>
  );
}

Object.assign(window, { CityMap });
