/* =============================================================================
   NewslettersPage — the "Newsletters" tab: a team + date selector that renders
   Will's newsletter "scaffolding" for the chosen issue.

   FIDELITY: this is a faithful web port of Will's Ghost email build-sheet
   (design-source/newsletter/Synth Issue - Rams Oct 20.html — 11 inline-styled
   cards). Will's markup is table-based + inline-styled for email; on the website
   we reproduce the SAME visual design with the same values/colors/type, laid out
   with the same structure. Where a card is data-driven it reads from
   window.SYNTH_ISSUES (newsletter_data_2.js). Charts render as static <img>
   slots (Will's rule: no interactive charts in the newsletter itself).

   Day-type variants (Aug 6 meeting note): full (Mon/Tue, full box score) /
   condensed (Wed/Thu, line score only) / preview (Fri/Sat, condensed + injury
   report) / bye (no game). Team Power + Coach Decision ranking cards are OMITTED
   for now (Will hasn't designed them) — marked slots left where they belong.
   ========================================================================== */

// ---- shared style constants (mirror Will's inline email values) ------------
const NL = {
  card:      { width:'100%', maxWidth:602, margin:'0 auto 18px', background:'#FFFFFF', border:'2px solid #E4DCCB', borderRadius:14, overflow:'hidden', boxSizing:'border-box' },
  plain:     { width:'100%', maxWidth:602, margin:'0 auto 14px', boxSizing:'border-box' },
  serif:     "'Newsreader', Georgia, serif",
  sans:      "'IBM Plex Sans', Helvetica, sans-serif",
  mono:      "'IBM Plex Mono', monospace",
  ink:       '#142B4D',
  ink2:      '#3B4A66',
  ink3:      '#566078',
  rule:      '#E4DCCB',
  ruleSoft:  '#EFE9DF',
  paper2:    '#F7F3EA',
  // Outer scaffold wrapper: caps the card column. Zero side padding + maxWidth == card width means
  // the screenshotted PNG has NO baked side margin, so the emailed card runs FLUSH to the page edge
  // on mobile (Will 2026-09-14: took it all the way flush to work back out from there). The real
  // card-to-page control for the email is the DISPLAY width cap (sec.EMAIL_CARD_MAX_W) + this having
  // no baked gutter. To add a little margin back later, bump padding here (e.g. '0 6px').
  scaffold:  { maxWidth:602, margin:'0 auto', padding:'0' },
};
// ---- type scale (Will 2026-09-14: content too small on mobile; wants 15% / 25% larger) ---------
// A single multiplier applied to EVERY fontSize in this file via fs(). It scales ONLY type, never
// padding/margin/width — so the layout box is unchanged and the text simply fills more of it (Will:
// "I don't want to change anything on the intra-newsletter spacing"). Unitless line-heights already
// track font-size, so lines stay proportionally spaced without touching the layout.
//   - Read from ?scale=1.15 / 1.25 on the export URL (render_email_from_site passes it), so we can
//     produce 100% / 115% / 125% renders for Will to compare WITHOUT editing 70 literals.
//   - Clamped to a sane [1, 1.5] band; a junk/absent value falls back to 1.0 (the current design).
// window.SYNTH_TYPE_SCALE lets non-export callers (the live selector page) set it too.
const TYPE_SCALE = (function(){
  try {
    var params = new URLSearchParams(location.search);
    // ?scale= only applies in the export/render surface (card_export=1). Gating it behind
    // card_export means a normal visitor who crafts readsynth.ca/?scale=1.5#newsletters gets the
    // designed size, not enlarged type — scale is a render-pipeline knob, not a public URL param.
    // window.SYNTH_TYPE_SCALE stays available if we ever want to scale the live page deliberately.
    var q = params.get('card_export') ? params.get('scale') : null;
    var s = q != null ? parseFloat(q) : (window.SYNTH_TYPE_SCALE);
    if (s == null || !isFinite(s)) return 1;
    return Math.min(1.5, Math.max(1, s));   // never shrink below the designed size, cap runaway
  } catch (e) { return 1; }
})();
// Round to 0.1px so rendered numbers stay clean; identity at scale 1 so the default render is
// byte-for-byte the current design.
function fs(px){ return TYPE_SCALE === 1 ? px : Math.round(px * TYPE_SCALE * 10) / 10; }
function teamColor(abbr){ return (window.SYNTH_TEAM_COLORS||{})[abbr] || {p:'#142B4D', ink:'#FFFFFF'}; }
// Which team is a CARD about? Team editions: the issue's team. LEAGUE editions: the card's own
// `team_tag` ("KC · Chiefs" / "BUF ↔ MIA" -> first key), the same resolution the card border already
// used. Without this everything inside a league card resolved through teamColor('LEAGUE'), which
// misses the map and falls back to navy — so all 32 teams' cards looked identical inside.
// Returns null when a league tag can't be resolved (missing tag / unknown key): the caller's signal
// to keep the neutral chrome rather than color the card wrongly.
function cardTeamKey(card, issue){
  if (card && card.team_tag) {
    const k = (card.team_tag.split(/[·↔]/)[0] || '').trim().split(/\s+/)[0];
    return (k && (window.SYNTH_TEAM_COLORS||{})[k]) ? k : null;
  }
  return issue ? issue.team : null;
}
// Blend hex `a` into hex `b` by t (0..1). The chart frame and the stat-table rule take only a few
// percent of the team color over Will's cream chrome — at full strength a team color there fights
// the card's own border and the chart inside it. Text keeps the primary at full strength: all 32
// primaries are dark (TEAM_COLORS in scripts/build_newsletter_data.py — the light values in that
// map are the SECONDARY, used as pill ink), so a primary always reads on cream.
function mixHex(a, b, t){
  const rgb = c => [1,3,5].map(i => parseInt(c.slice(i,i+2),16));
  const A = rgb(a), B = rgb(b);
  return '#' + [0,1,2].map(i => Math.round(A[i]*t + B[i]*(1-t)).toString(16).padStart(2,'0')).join('');
}

// ---- card chrome: eyebrow (dash + label) ----------------------------------
function Eyebrow({ label, color }) {
  return (
    <div>
      <span style={{ display:'inline-block', width:20, height:3, background:color, borderRadius:2, verticalAlign:'middle' }} />
      <span style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color, marginLeft:9, verticalAlign:'middle' }}>{label}</span>
    </div>
  );
}

// ---- 01 masthead -----------------------------------------------------------
function Masthead({ issue }) {
  const tc = teamColor(issue.team);
  const tn = (window.SYNTH_TEAM_NAMES||{})[issue.team] || {city:issue.team, name:''};
  const m = issue.masthead || {};
  return (
    <div style={NL.card}>
      <div style={{ height:6, background:tc.p }} />
      <div style={{ padding:'22px 26px 0', display:'flex', alignItems:'center', justifyContent:'space-between' }}>
        <span style={{ display:'flex', alignItems:'center' }}>
          <img src="../assets/synth-logo-mono-outline.svg" width="44" height="44" alt="Synth" style={{ width:44, height:44, verticalAlign:'middle' }} />
          <span style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(27), letterSpacing:'-0.03em', color:NL.ink, lineHeight:1, marginLeft:9, position:'relative', top:4 }}>Synth</span>
        </span>
        <span style={{ textAlign:'right', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.04em', color:NL.ink3, lineHeight:1.6 }}>
          {m.dateLine}<br/>{m.weekLabel} · {m.editionLabel}
        </span>
      </div>
      <div style={{ padding:'16px 26px 22px' }}>
        <div style={{ borderTop:`1px solid ${NL.rule}`, paddingTop:14 }}>
          <span style={{ display:'inline-block', fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.1em', color:tc.ink, background:tc.p, padding:'4px 11px', borderRadius:999, verticalAlign:'middle' }}>{issue.team}</span>
          <span style={{ fontFamily:NL.serif, fontWeight:600, fontSize:fs(21), letterSpacing:'-0.02em', color:NL.ink, verticalAlign:'middle', marginLeft:10 }}>{tn.city} {tn.name} edition</span>
        </div>
      </div>
    </div>
  );
}

// ---- 02 gamecard: box score (+ leaders when full/preview) + next game ------
function StatLeaders({ side, color }) {
  const tc = teamColor(side.abbr);
  return (
    <div>
      <div style={{ display:'flex', alignItems:'center', margin:'18px 0 6px' }}>
        <span style={{ display:'inline-block', fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.1em', color:tc.ink, background:tc.p, padding:'3px 9px', borderRadius:999 }}>{side.abbr}</span>
        <span style={{ fontFamily:NL.sans, fontSize:fs(12.5), color:NL.ink, marginLeft:9 }}>{side.city}</span>
      </div>
      <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}><tbody>
        {(side.leaders||[]).map((L,i)=>(
          <tr key={i}>
            <td style={{ width:'11%', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.08em', color:NL.ink3, padding:'7px 0', borderTop:i?`1px solid ${NL.ruleSoft}`:'none' }}>{L.pos}</td>
            <td style={{ width:'33%', fontFamily:NL.sans, fontSize:fs(12), color:NL.ink2, padding:'7px 0', borderTop:i?`1px solid ${NL.ruleSoft}`:'none', whiteSpace:'nowrap' }}>{L.name}</td>
            {['a','b','c'].map((k,j)=>(
              <td key={k} style={{ width:'18.6%', fontFamily:NL.mono, fontSize:fs(11), textAlign:'right', fontVariantNumeric:'tabular-nums', color:NL.ink2, padding:'7px 0', borderTop:i?`1px solid ${NL.ruleSoft}`:'none' }}>{L[k]}</td>
            ))}
          </tr>
        ))}
      </tbody></table>
    </div>
  );
}
function BoxScore({ bs, showLeaders }) {
  const homeC = teamColor(bs.home.abbr), awayC = teamColor(bs.away.abbr);
  const row = (side, tint) => (
    <tr>
      <td style={{ fontFamily:NL.mono, fontSize:fs(12.5), fontWeight:side.win?600:400, color:side.win?NL.ink:NL.ink3, padding:'5px 0', borderTop:tint?`1px solid ${NL.ruleSoft}`:'none' }}>{side.abbr}</td>
      {side.line.map((q,i)=>(
        <td key={i} style={{ textAlign:'center', fontFamily:NL.mono, fontSize:fs(13), color:q?NL.ink2:NL.ink3, padding:'5px 0', borderTop:tint?`1px solid ${NL.ruleSoft}`:'none', fontVariantNumeric:'tabular-nums' }}>{q}</td>
      ))}
      <td style={{ textAlign:'right', fontFamily:NL.sans, fontSize:fs(22), fontWeight:side.win?700:500, color:side.win?teamColor(side.abbr).p:NL.ink3, padding:'5px 0', borderTop:tint?`1px solid ${NL.ruleSoft}`:'none', fontVariantNumeric:'tabular-nums' }}>{side.total}</td>
    </tr>
  );
  return (
    <div>
      <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}><tbody>
        <tr>
          <td style={{ width:74 }} />
          {['1','2','3','4'].map(n=>(<td key={n} style={{ textAlign:'center', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.08em', color:NL.ink3, padding:'0 0 2px' }}>{n}</td>))}
          <td style={{ width:66 }} />
        </tr>
        {row(bs.home, false)}
        {row(bs.away, true)}
      </tbody></table>
      {showLeaders && (<div>
        <StatLeaders side={bs.home} color={homeC.p} />
        <StatLeaders side={bs.away} color={awayC.p} />
      </div>)}
    </div>
  );
}
// Look up a team's record from the standings block (around_the_league). The next_game data only
// carries the FOCAL team's record (focal_team_record), so the opponent's cell would be blank; the
// standings carry every team's record, so we fall back to it for whichever side is missing one.
function recordFromStandings(issue, teamKey) {
  const conf = ((issue.standings || {}).conferences) || {};
  for (const cname of Object.keys(conf)) {
    const c = conf[cname] || {};
    for (const bucket of ['seeded', 'out']) {
      for (const r of (c[bucket] || [])) {
        if (r.key === teamKey && r.record) return r.record;
      }
    }
  }
  return '';
}
function NextGame({ ng, issue }) {
  const hc = teamColor(ng.home.abbr), ac = teamColor(ng.away.abbr);
  const teamCol = (side, tc) => {
    const rec = side.record || recordFromStandings(issue, side.abbr);
    return (
    <td style={{ width:'30%', verticalAlign:'top', padding:'2px 4px 0' }}>
      <div style={{ width:38, height:38, borderRadius:'50%', background:tc.p, margin:'0 auto', textAlign:'center', lineHeight:'38px', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.04em', color:tc.ink }}>{side.abbr}</div>
      <div style={{ fontFamily:NL.sans, fontSize:fs(12), letterSpacing:'.06em', textTransform:'uppercase', color:NL.ink, textAlign:'center', marginTop:9, lineHeight:1.2 }}>{side.name}</div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(11), color:NL.ink3, textAlign:'center', marginTop:4 }}>{rec}</div>
    </td>
  ); };
  return (
    <div style={{ padding:'14px 26px 18px', background:NL.paper2, borderTop:`1px solid ${NL.rule}` }}>
      <div style={{ display:'flex', justifyContent:'space-between', paddingBottom:12 }}>
        <span style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3 }}>Next game</span>
        <span style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.06em', color:NL.ink3 }}>{ng.weekLabel}</span>
      </div>
      <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}><tbody><tr>
        {teamCol(ng.home, hc)}
        <td style={{ width:'40%', verticalAlign:'top', textAlign:'center', padding:'6px 2px 0' }}>
          <div style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.12em', textTransform:'uppercase', color:NL.ink3 }}>{ng.dayDate}</div>
          <div style={{ fontFamily:NL.serif, fontSize:fs(19), fontWeight:600, letterSpacing:'-0.02em', color:NL.ink, margin:'5px 0', whiteSpace:'nowrap' }}>{ng.kickoff}</div>
          <div style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.12em', textTransform:'uppercase', color:NL.ink3 }}>{ng.tv}</div>
        </td>
        {teamCol(ng.away, ac)}
      </tr></tbody></table>
      <div style={{ textAlign:'center', borderTop:`1px solid ${NL.rule}`, marginTop:14, paddingTop:12, fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.1em', textTransform:'uppercase', color:NL.ink3 }}>
        {ng.location}{ ng.betting ? <React.Fragment> &nbsp;·&nbsp; <b style={{ color:NL.ink }}>{ng.betting}</b></React.Fragment> : '' }
      </div>
    </div>
  );
}
function GameCard({ issue }) {
  const bs = issue.boxScore, ng = issue.nextGame, dt = issue.dayType;
  if (dt === 'bye') {
    return (
      <div style={NL.card}>
        <div style={{ padding:'18px 26px', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.06em', color:NL.ink3, textAlign:'center' }}>Bye week — no game this week.</div>
        {ng && <NextGame ng={ng} issue={issue} />}
      </div>
    );
  }
  const showLeaders = (dt === 'full');  // condensed/preview drop player stats
  return (
    <div style={NL.card}>
      <div style={{ padding:'16px 26px 12px', background:NL.paper2, borderBottom:`1px solid ${NL.rule}`, display:'flex', justifyContent:'space-between' }}>
        <span style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3 }}>{bs.status}</span>
        <span style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.06em', color:NL.ink3 }}>{bs.location}</span>
      </div>
      <div style={{ padding:'18px 26px 16px' }}>
        <BoxScore bs={bs} showLeaders={showLeaders} />
      </div>
      {ng && <NextGame ng={ng} issue={issue} />}
    </div>
  );
}

// ---- 03 opening (drop-cap) -------------------------------------------------
function Opening({ issue }) {
  const tc = teamColor(issue.team);
  const txt = issue.opening || '';
  const first = txt.charAt(0), rest = txt.slice(1);
  // Peel char 0 into the drop-cap UNLESS it starts a markdown token (`[` of a
  // link or `*` of bold) — peeling that char would split the token and print
  // literal markdown. A quoted/numeric/accented opening (`"`, `3`, `Étienne`)
  // is ordinary prose and KEEPS its drop cap; only the actual hazard is guarded.
  const dropCap = first !== '[' && first !== '*';
  const rule = <div style={{ height:3, background:tc.p, borderRadius:2 }} />;
  return (
    <div style={NL.plain}>
      <div style={{ padding:'10px 26px 16px' }}>
        <div style={{ marginBottom:16 }}>{rule}</div>
        <div style={{ fontFamily:NL.serif, fontSize:fs(19), lineHeight:1.5, color:NL.ink, letterSpacing:'-0.005em' }}>
          {dropCap
            ? <React.Fragment><span style={{ float:'left', fontFamily:NL.serif, fontSize:fs(56), lineHeight:0.8, fontWeight:600, color:tc.p, margin:'5px 9px 0 0' }}>{first}</span>{inlineMd(rest)}</React.Fragment>
            : inlineMd(txt)}
        </div>
        <div style={{ marginTop:16 }}>{rule}</div>
      </div>
    </div>
  );
}

// ---- 04 fan sentiment (ring + aspects) -------------------------------------
function SentimentRing({ score, delta, up, color }) {
  const r=42, c=2*Math.PI*r, pct=Math.max(0,Math.min(100, score||0));
  const dash = (pct/100)*c;
  return (
    <svg width="116" height="116" viewBox="0 0 100 100" style={{ display:'block' }}>
      <circle cx="50" cy="50" r={r} fill="none" stroke="#ECE5D6" strokeWidth="9" />
      <circle cx="50" cy="50" r={r} fill="none" stroke={color} strokeWidth="9" strokeLinecap="round"
        strokeDasharray={`${dash} ${c}`} transform="rotate(-90 50 50)" />
      <text x="50" y="49" textAnchor="middle" style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(27), fill:NL.ink }}>{score==null?'—':score}</text>
      <text x="50" y="66" textAnchor="middle" style={{ fontFamily:NL.mono, fontSize:fs(10), fill: up?'#2E7D5B':'#B83A2D' }}>{delta||''}</text>
    </svg>
  );
}
function Sentiment({ issue }) {
  const tc = teamColor(issue.team), s = issue.sentiment || {};
  return (
    <div style={NL.card}>
      <div style={{ padding:'20px 26px 0' }}><Eyebrow label="Fan sentiment" color={tc.p} /></div>
      <div style={{ padding:'14px 26px 4px', display:'flex', alignItems:'center', gap:18 }}>
        <div style={{ flex:'0 0 auto' }}><SentimentRing score={s.score} delta={s.delta} up={s.up} color={tc.p} /></div>
        <div>
          <h3 style={{ margin:'0 0 6px', fontFamily:NL.serif, fontWeight:600, fontSize:fs(20), letterSpacing:'-0.02em', color:NL.ink }}>{inlineMd(s.headline)}</h3>
          <p style={{ margin:0, fontFamily:NL.sans, fontSize:fs(14), lineHeight:1.6, color:NL.ink2 }}>{inlineMd(s.blurb)}</p>
        </div>
      </div>
      <div style={{ padding:'14px 26px 20px' }}>
        <div style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3, margin:'6px 0 8px' }}>Top aspects driving fan conversation</div>
        <ol style={{ margin:0, paddingLeft:18, fontFamily:NL.sans, fontSize:fs(13.5), lineHeight:1.7, color:NL.ink2 }}>
          {/* inlineMd, like every other prose surface: an aspect is writer prose and can carry
              a **bold** or a source credit. Rendering it raw printed the markup. */}
          {(s.aspects||[]).map((a,i)=>(<li key={i}>{inlineMd(a)}</li>))}
        </ol>
      </div>
      {/* Team Power Rankings slot (Wed/Thu only) — OMITTED for now (Will hasn't designed it). */}
    </div>
  );
}

// ---- inline markdown (**bold** → <strong>, [text](url) → <a>) ---------------
// Reporter source links: analysis-live attaches the credit as `[Name](url)` on
// headlines AND body prose (per TO_GHOST_WEBSITE_source_links_render_anchors.md,
// 2026-09-08). Only the reporter's NAME is wrapped. Hosts are Twitter/X and
// Bluesky ONLY — we enforce that with a strict allowlist (x.com, twitter.com,
// bsky.app): a link whose host is not on it renders as PLAIN TEXT (the visible
// label), never as a live anchor. twitter.com is included because a legacy X
// status URL may still use that host. The writer is banned from emitting links
// (verifier R23), so any
// [text](url) reaching us was attached deterministically from a verified claim,
// not authored by the model. React auto-escapes the extracted label + href, so
// no raw HTML is ever injected.
// The email image-render pipeline loads the page with ?card_export=1 and screenshots
// each card to a PNG. A link inside a PNG is NOT clickable, so we drop the underline
// there — otherwise reporter names look like tappable links that do nothing in email.
// On the live website (no card_export) links keep their underline and work normally.
var IS_CARD_EXPORT = (function () {
  try { return new URLSearchParams(location.search).get('card_export') != null; }
  catch (e) { return false; }
})();
var LINK_HOST_ALLOWLIST = ['x.com', 'twitter.com', 'bsky.app'];
function isAllowlistedUrl(url) {
  var m = /^https?:\/\/([^/?#]+)/i.exec(url || '');
  if (!m) return false;
  var host = m[1].toLowerCase().replace(/^www\./, '');
  return LINK_HOST_ALLOWLIST.indexOf(host) !== -1;
}
// Single tokenizer pass over the string. ONE regex, its own capture groups —
// no second re-parse (that was the trap: a split()+re-exec design silently
// mangled bold-wrapping-a-link, scheme-less URLs, and paren URLs). Group 1 =
// **bold** inner text; groups 2+3 = [label](url). The `i` flag matches the
// scheme case-insensitively so `[Name](HTTPS://x.com/1)` still links.
// The link URL body is `[^)\s]+` — ANY [label](url) shape is captured (not just
// https), so isAllowlistedUrl is the SOLE gate: a scheme-less or non-allowlisted
// link (e.g. `[Joe](x.com/a)` or a podcast URL) renders as the plain-text LABEL,
// never as raw `[..](..)` brackets and never as a live anchor — the contract this
// component promises. The attacher (analysis-live) emits X/Bluesky status URLs
// only, which never contain a literal ')'; a URL with '(' or ')' would truncate
// here, so percent-encode parens on the attacher side if that ever changes.
var INLINE_RE = /\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/gi;
function inlineMd(text) {
  const s = String(text || '');
  const out = [];
  let last = 0, m, key = 0;
  INLINE_RE.lastIndex = 0;
  while ((m = INLINE_RE.exec(s)) !== null) {
    if (m.index > last) out.push(<React.Fragment key={key++}>{s.slice(last, m.index)}</React.Fragment>);
    if (m[1] !== undefined) {
      // Recurse so a link nested inside bold (`**[Name](url)**`) still renders as a
      // link, not raw markdown. Not expected from the attacher, but cheap to be safe.
      out.push(<strong key={key++}>{inlineMd(m[1])}</strong>);
    } else {
      const label = m[2], url = m[3];
      // Allowlisted host → real anchor; otherwise show the label as plain text.
      // In export (email PNG) mode the anchor can't be clicked, so render the label
      // as plain text with no underline — no misleading "link" styling in the image.
      out.push(isAllowlistedUrl(url) && !IS_CARD_EXPORT
        ? <a key={key++} href={url} target="_blank" rel="noopener noreferrer" style={{ color:'inherit', textDecoration:'underline', textUnderlineOffset:'2px' }}>{label}</a>
        : <React.Fragment key={key++}>{label}</React.Fragment>);
    }
    last = INLINE_RE.lastIndex;
  }
  if (last < s.length) out.push(<React.Fragment key={key++}>{s.slice(last)}</React.Fragment>);
  return out;
}
// A card body block: paragraph | bullets | table. Bet disclaimer paras (leading *) render italic.
function Block({ block, accent }) {
  if (block.kind === 'para') {
    const t = block.text || '';
    const isDisc = t.startsWith('*') && t.endsWith('*') && !t.startsWith('**');
    // The disclaimer is prose too — it was the one branch in Block that skipped inlineMd.
    if (isDisc) return <p style={{ margin:'0 0 12px', fontFamily:NL.sans, fontSize:fs(11), lineHeight:1.55, color:NL.ink3, fontStyle:'italic' }}>{inlineMd(t.slice(1,-1))}</p>;
    return <p style={{ margin:'0 0 12px', fontFamily:NL.sans, fontSize:fs(15), lineHeight:1.65, color:NL.ink2 }}>{inlineMd(t)}</p>;
  }
  if (block.kind === 'bullets') {
    return <ul style={{ margin:'0 0 14px', paddingLeft:20, fontFamily:NL.sans, fontSize:fs(14.5), lineHeight:1.7, color:NL.ink2 }}>
      {block.items.map((b,i)=>(<li key={i}>{inlineMd(b)}</li>))}
    </ul>;
  }
  if (block.kind === 'table') {
    const rows = block.rows || [];
    if (!rows.length) return null;
    const [head, ...body] = rows;
    return (
      <div style={{ overflowX:'auto', margin:'0 0 14px' }}>
        <table style={{ width:'100%', borderCollapse:'collapse', fontFamily:NL.mono, fontSize:fs(12) }}><tbody>
          {/* header row carries the card team's color: the label in the primary, the rule a 45%
              blend of it into the standard tan — a Stat table reads as that team's stat block without
              recoloring the numbers, which stay ink for legibility. `accent` is absent on any other
              use of Block, and then this is exactly the old neutral header. */}
          <tr>{head.map((c,i)=>(<td key={i} style={{ textAlign: i?'right':'left', padding:'6px 8px', borderBottom:`1px solid ${accent ? mixHex(accent, NL.rule, 0.45) : NL.rule}`, color:accent || NL.ink3, fontWeight:600, letterSpacing:'.02em' }}>{inlineMd(c)}</td>))}</tr>
          {body.map((r,ri)=>(<tr key={ri}>{r.map((c,ci)=>(<td key={ci} style={{ textAlign: ci?'right':'left', padding:'6px 8px', borderBottom:`1px solid ${NL.ruleSoft}`, color: ci?NL.ink:NL.ink2, fontVariantNumeric:'tabular-nums' }}>{inlineMd(c)}</td>))}</tr>))}
        </tbody></table>
      </div>
    );
  }
  return null;
}
// ---- 05-09 content cards (Story / Stat / Bet from parsed rendered_body) -----
function ContentCard({ issue, card, dateLabel }) {
  // The team THIS CARD is about (league: its own team_tag; team edition: the issue's team). Every
  // team-colored element below — eyebrow, chart pill, chart frame, stat-table header — reads from it.
  const ctKey = cardTeamKey(card, issue);
  const tc = teamColor(ctKey || issue.team);   // ctKey null (unresolved league tag) -> navy fallback
  const isBet = card.type === 'Bet';
  // Card border color. TEAM editions unchanged: Bet card = Synth green, others = default tan.
  // LEAGUE edition (Will, updated 2026-09-08): EVERY card's border is the primary color of the team
  // it's about, taken from card.team_tag ("KC · Chiefs" / "BUF ↔ MIA" -> first team key ->
  // TEAM_COLORS[key].p). Two/three-team cards use the FIRST team. If the tag/color can't be
  // resolved: league cards fall back to the normal tan border; team Bet keeps green.
  const isLeague = !!card.team_tag;
  let borderColor = null;                 // null => use NL.card's default tan border
  if (isLeague) {
    if (ctKey) borderColor = tc.p;        // team-color border for the whole league card
  } else if (isBet) {
    borderColor = '#2E7D5B';              // team-edition Bet card: green (unchanged)
  }
  const cardStyle = Object.assign({}, NL.card, borderColor ? { border:`2px solid ${borderColor}` } : {});
  // Chart-frame chrome tinted by the card's team: 6% of the primary over Will's cream bar, 28% into
  // the bar rule. Deliberately faint — the frame should read as that team's without competing with
  // the chart inside it. No resolved team -> the original fixed cream, unchanged.
  const barBg   = ctKey ? mixHex(tc.p, '#F4EFE6', 0.06) : '#F4EFE6';
  const barRule = ctKey ? mixHex(tc.p, '#DCD5C6', 0.28) : '#DCD5C6';
  return (
    <div style={cardStyle}>
      <div style={{ padding:'20px 26px 0', display:'flex', justifyContent:'space-between', alignItems:'center' }}>
        <Eyebrow label={card.type} color={tc.p} />
        {/* Will (2026-08-27): do NOT repeat the date here — it's already at the top-right of the
            masthead. Show the team slug. For a LEAGUE edition each card carries its own team_tag
            (e.g. "ATL · Falcons" / "BUF ↔ MIA") — show that instead of the generic 'LEAGUE'
            (analysis-live 2026-09-07). */}
        <span style={{ fontFamily:NL.mono, fontSize:fs(10.5), letterSpacing:'.08em', textTransform:'uppercase', color:NL.ink3 }}>{card.team_tag || issue.team}</span>
      </div>
      {card.headline && (
        <div style={{ padding:'13px 26px 0' }}>
          <h3 style={{ margin:0, fontFamily:NL.serif, fontWeight:600, fontSize:fs(24), lineHeight:1.18, letterSpacing:'-0.025em', color:NL.ink }}>{inlineMd(card.headline)}</h3>
        </div>
      )}
      <div style={{ padding:'12px 26px 0' }}>
        {(card.blocks||[]).map((b,i)=>(<Block key={i} block={b} accent={ctKey ? tc.p : null} />))}
      </div>
      {card.chart && card.chart.src && (
        /* Will's framed chart block (design-source CARD06): a header bar ("<Type> · <date>" +
           team pill), the chart on cream, and a footer bar ("SYNTH" + source layer). Full-bleed
           inside the card because it is a direct, un-padded child of the card root (no side padding
           of its own), NOT via any negative margin. */
        <div style={{ margin:'8px 0 0', borderTop:`1px solid ${barRule}`, borderBottom:`1px solid ${barRule}`, background:'#FBF7F1' }}>
          {/* header bar */}
          <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:38, padding:'0 24px', background:barBg, borderBottom:`1px solid ${barRule}` }}>
            <span style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.12em', textTransform:'uppercase', color:NL.ink3, fontWeight:500 }}>{card.type} · {dateLabel}</span>
            {/* pill: the team the card is about, in that team's own colors. League cards were pinned
                to one hardcoded blue (#003594) for all 32 teams; the card's team now drives it, and an
                unresolved tag falls back to teamColor()'s navy. */}
            <span style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.08em', fontWeight:600, color:tc.ink, background:tc.p, padding:'3px 10px', borderRadius:999 }}>{card.team_tag || issue.team}</span>
          </div>
          {/* chart body: title/subtitle then the PNG */}
          <div style={{ padding:'20px 24px 18px' }}>
            {card.chart.title && (
              <div style={{ fontFamily:NL.sans, fontWeight:600, fontSize:fs(18), lineHeight:1.25, color:NL.ink, letterSpacing:'-0.01em' }}>{inlineMd(card.chart.title)}</div>
            )}
            {card.chart.subtitle && (
              <div style={{ fontFamily:NL.sans, fontSize:fs(12.5), color:NL.ink3, marginTop:4 }}>{inlineMd(card.chart.subtitle)}</div>
            )}
            <img src={card.chart.src} alt={card.chart.alt||''} style={{ width:'100%', maxWidth:552, height:'auto', display:'block', margin:'16px auto 0' }} />
          </div>
          {/* footer bar */}
          <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', height:34, padding:'0 24px', background:barBg, borderTop:`1px solid ${barRule}` }}>
            <span style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.14em', fontWeight:600, color:NL.ink }}>SYNTH</span>
            <span style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.08em', color:NL.ink3 }}>{inlineMd(card.chart.source || '')}</span>
          </div>
        </div>
      )}
      <div style={{ padding:'0 26px 18px' }} />
    </div>
  );
}
function NewsletterBody({ issue }) {
  const nl = issue.newsletter || {};
  const dateLabel = (issue.masthead && issue.masthead.dateLine) ? issue.masthead.dateLine.split(' · ').pop() : issue.date;
  // A league edition with no parsed cards (non-card-formatted body) has already shown its intro
  // prose above; don't fall through to the TEAM "not generated yet" placeholder (reviewer MEDIUM#2).
  if (nl.league && (!nl.cards || nl.cards.length === 0)) return null;
  if (nl.status !== 'present' || !nl.cards || nl.cards.length === 0) {
    return (
      <div style={Object.assign({}, NL.card, { borderStyle:'dashed' })}>
        <div style={{ padding:'40px 26px', textAlign:'center', fontFamily:NL.mono, fontSize:fs(12), letterSpacing:'.04em', color:NL.ink3 }}>
          {nl.placeholder || 'Newsletter not generated yet for this date.'}<br/>
          <span style={{ fontSize:fs(11), color:'#8A93A6' }}>The analysis-agent cards (Story / Stat / Bet) will appear here once produced.</span>
        </div>
      </div>
    );
  }
  return (<div>{nl.cards.map((c,i)=>(<ContentCard key={i} issue={issue} card={c} dateLabel={dateLabel} />))}</div>);
}

// ---- injury / availability (only when window_active, per the DB) -----------
function InjuryReport({ issue }) {
  const inj = issue.injury;
  if (!inj || !inj.active) return null;   // honor the analysis-side window_active flag
  const tc = teamColor(issue.team);
  const roster = inj.roster || [];
  return (
    <div style={NL.card}>
      <div style={{ padding:'20px 26px 0' }}><Eyebrow label="Injury report" color={tc.p} /></div>
      <div style={{ padding:'12px 26px 20px' }}>
        <div style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.04em', color:'#8A93A6', marginBottom:10 }}>Roster status (IR/PUP/etc.). Weekly Questionable/Probable feed is deferred.</div>
        <table style={{ width:'100%', borderCollapse:'collapse' }}><tbody>
          {roster.map((r,i)=>(
            <tr key={i}>
              <td style={{ fontFamily:NL.sans, fontSize:fs(13), color:NL.ink2, padding:'7px 0', borderTop:i?`1px solid ${NL.ruleSoft}`:'none' }}>{r.name}{r.position?<span style={{ color:NL.ink3, marginLeft:8, fontFamily:NL.mono, fontSize:fs(11) }}>{r.position}</span>:''}</td>
              <td style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.06em', textTransform:'uppercase', textAlign:'right', color:NL.ink3, padding:'7px 0', borderTop:i?`1px solid ${NL.ruleSoft}`:'none' }}>{r.status}</td>
            </tr>
          ))}
        </tbody></table>
      </div>
    </div>
  );
}

// ---- 10 around the league (real AFC/NFC standings tables) ------------------
function ConfTable({ conf, rows, focalTeam }) {
  const seeded = rows.seeded || [];
  const out = rows.out || [];
  const line = (label) => (
    <tr><td colSpan={3} style={{ padding:'6px 0 6px', borderTop:`1px solid ${NL.rule}`, borderBottom:`1px solid ${NL.rule}`, fontFamily:NL.mono, fontSize:fs(9.5), letterSpacing:'.14em', textTransform:'uppercase', color:'#B0453B', fontWeight:600 }}>{label}</td></tr>
  );
  const teamRow = (r, seededRow) => {
    const tc = teamColor(r.key);
    const focal = r.key === focalTeam;
    return (
      <tr key={r.key} style={ focal ? { background:'rgba(0,0,0,0.03)' } : null }>
        <td style={{ padding:'7px 0', fontFamily:NL.mono, fontSize:fs(11), color:NL.ink3, width:fs(20), textAlign:'center' }}>{seededRow ? r.rank : '—'}</td>
        <td style={{ padding:'7px 0', whiteSpace:'nowrap' }}>
          <span style={{ display:'inline-block', fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.06em', color:tc.ink, background:tc.p, padding:'2px 7px', borderRadius:999, verticalAlign:'middle' }}>{r.key}</span>
          <span className="syn-standings-name" style={{ fontFamily:NL.sans, fontSize:fs(12.5), color:NL.ink2, marginLeft:8, verticalAlign:'middle', fontWeight: focal?600:400, whiteSpace:'nowrap' }}>{r.name}</span>
        </td>
        <td style={{ padding:'7px 0 7px 4px', textAlign:'right', fontFamily:NL.mono, fontSize:fs(11.5), color:NL.ink, fontVariantNumeric:'tabular-nums', width:fs(72), whiteSpace:'nowrap' }}>
          {r.record}{ seededRow ? '' : (r.games_back!=null ? <span style={{ color:NL.ink3, marginLeft:8 }}>{r.games_back} GB</span> : '') }
        </td>
      </tr>
    );
  };
  return (
    <div style={{ flex:1, minWidth:0 }}>
      <div style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3, paddingBottom:8, borderBottom:`1px solid ${NL.rule}`, marginBottom:6 }}>{conf}</div>
      <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}><tbody>
        {seeded.map(r=>teamRow(r, true))}
        {rows.playoff_line ? line('Playoff line') : null}
        {out.map(r=>teamRow(r, false))}
      </tbody></table>
    </div>
  );
}
function Standings({ issue }) {
  const tc = teamColor(issue.team), st = issue.standings;
  const conf = st && st.conferences;
  return (
    <div>
      <div style={NL.plain}>
        <div style={{ padding:'10px 26px 0' }}>
          <div style={{ height:3, background:tc.p, borderRadius:2, marginBottom:14 }} />
          <div style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:tc.p }}>Around the league</div>
          <div style={{ fontFamily:NL.serif, fontWeight:600, fontSize:fs(23), lineHeight:1.2, letterSpacing:'-0.02em', color:NL.ink, marginTop:8 }}>League standings through Week {st ? st.as_of_week : '—'}</div>
        </div>
      </div>
      <div style={Object.assign({}, NL.card, conf ? {} : { borderStyle:'dashed' })}>
        {conf ? (
          <div style={{ padding:'20px 26px', display:'flex', gap:34, flexWrap:'wrap' }}>
            <ConfTable conf="AFC" rows={conf.AFC||{}} focalTeam={issue.team} />
            <ConfTable conf="NFC" rows={conf.NFC||{}} focalTeam={issue.team} />
          </div>
        ) : (
          <div style={{ padding:'22px 26px', textAlign:'center', fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.04em', color:NL.ink3 }}>Standings not available for this date.</div>
        )}
        {conf && (
          <div style={{ padding:'0 26px 18px', fontFamily:NL.mono, fontSize:fs(10.5), color:NL.ink3 }}>
            <span style={{ textDecoration:'underline', textDecorationColor:'#A7A091', textUnderlineOffset:3 }}>NFL standings · through {st.as_of_date}</span>
          </div>
        )}
      </div>
      {/* Coach Decision Rankings slot (Wed/Thu, below Bet/standings) — OMITTED for now. */}
    </div>
  );
}

// ---- 11 footer -------------------------------------------------------------
function NLFooter() {
  return (
    <div style={{ width:'100%', maxWidth:602, margin:'0 auto' }}>
      <div style={{ padding:'20px 26px 8px', borderTop:'1px solid #DCD5C6', display:'flex', justifyContent:'space-between', alignItems:'baseline' }}>
        <span style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(17), letterSpacing:'-0.02em', color:NL.ink }}>Synth</span>
        <span style={{ fontFamily:NL.mono, fontSize:fs(11), color:NL.ink3 }}>
          <a href="#newsletters" style={{ color:'#9A530F', textDecoration:'none' }}>Archive</a> &nbsp;·&nbsp;
          <a href="#onboarding" style={{ color:'#9A530F', textDecoration:'none' }}>Change team</a> &nbsp;·&nbsp;
          <a href="https://signup.readsynth.ca/unsubscribe" style={{ color:'#9A530F', textDecoration:'none' }}>Unsubscribe</a>
        </span>
      </div>
      <div style={{ padding:'0 26px 24px', fontFamily:NL.mono, fontSize:fs(11), lineHeight:1.6, color:NL.ink3, marginTop:12 }}>
        The context engine for sports. Data in this newsletter is generated by AI, which can make mistakes. Please double-check responses. If you're going to gamble, play responsibly and according to laws in your local jurisdiction.<br/>Synth · Toronto, ON
      </div>
    </div>
  );
}

// ---- the scaffolding (11 sections, top to bottom) --------------------------
// `exportMode` (used by the email image-render pipeline) wraps each section in a
// stable data-card anchor so the renderer can screenshot each card individually.
// It changes NOTHING about the normal website render (default false).
function Scaffolding({ issue, exportMode }) {
  if (exportMode) {
    const A = ({name, children}) => (<div data-card={name}>{children}</div>);
    return (
      <div data-scaffold="1" style={NL.scaffold}>
        <A name="masthead"><Masthead issue={issue} /></A>
        <A name="opening"><Opening issue={issue} /></A>
        <A name="gamecard"><GameCard issue={issue} /></A>
        <A name="injury"><InjuryReport issue={issue} /></A>
        <A name="sentiment"><Sentiment issue={issue} /></A>
        <A name="body"><NewsletterBody issue={issue} /></A>
        <A name="standings"><Standings issue={issue} /></A>
        <A name="footer"><NLFooter /></A>
      </div>
    );
  }
  return (
    <div style={NL.scaffold}>
      <Masthead issue={issue} />
      <Opening issue={issue} />
      <GameCard issue={issue} />
      <InjuryReport issue={issue} />
      <Sentiment issue={issue} />
      <NewsletterBody issue={issue} />
      <Standings issue={issue} />
      <NLFooter />
    </div>
  );
}
window.SynthScaffolding = Scaffolding;

// ---- All 32: the 2026-09-21 redesign ---------------------------------------
// The league edition ("Synth · All 32") is a teamless, 1/day issue selectable from the same
// dropdown as the 32 teams via this sentinel key. Its data is window.SYNTH_LEAGUE_EDITIONS
// (date-keyed). Since the redesign it also carries four league modules — sentiment_pair, slate,
// records and standings — attached by build_newsletter_data.league_issue from the TEAM slate.
//
// ⚠️ These components are a SECOND IMPLEMENTATION of ghost/a32_cards.py, which renders the same
// edition for email. Change both in the same PR — the two forking is exactly what produced the
// 2026-09-18 design regression.
const LEAGUE_KEY = 'LEAGUE';
const A32 = {
  mastRule:'#34496B', mastMeta:'#A9B6CC', mastKicker:'#F6D889',
  hair:'#DCD5C6', hairIn:'#EFE9DC', hairRow:'#F2ECE1',
  positive:'#1F7A4D', negative:'#B22234', scoreMuted:'#8A8578', scoreDash:'#C6BFB0',
  ringTrack:'#EAE3D6',
};
// Contrast-corrected team colour for SMALL TEXT. Mirrors synth_email_cards.TEXT_SAFE: three
// primaries fail AA at body size on a light ground, and these are those primaries darkened in
// place (hue and saturation untouched) until they clear 4.5:1.
const TEXT_SAFE = { DET:'#006FAB', KC:'#D11633', MIA:'#00747C' };
const PUCK_FILL = { MIA:'#00747C' };          // white ink on MIA's teal is only 3.95:1
const PUCK_INK  = { CHI:'#FFFFFF', SF:'#FFFFFF', MIA:'#FFFFFF' };
function textSafe(k){ return TEXT_SAFE[k] || teamColor(k).p; }
function puck(k){ const t=teamColor(k); return { fill: PUCK_FILL[k]||t.p, ink: PUCK_INK[k]||t.ink }; }
// Band tint for the team-edition section of a merged card: the team's own hue, very light.
// Mirrors synth_email_cards.band_tint.
function bandTint(k){
  const hex=teamColor(k).p.replace('#','');
  const r=parseInt(hex.slice(0,2),16)/255, g=parseInt(hex.slice(2,4),16)/255, b=parseInt(hex.slice(4,6),16)/255;
  const mx=Math.max(r,g,b), mn=Math.min(r,g,b); let h=0;
  if(mx!==mn){ const d=mx-mn;
    h = mx===r ? ((g-b)/d+(g<b?6:0)) : mx===g ? ((b-r)/d+2) : ((r-g)/d+4); h/=6; }
  const hsl=(l,s)=>{ const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q;
    const f=t=>{ t=(t+1)%1; return t<1/6?p+(q-p)*6*t : t<1/2?q : t<2/3?p+(q-p)*(2/3-t)*6 : p; };
    const to=v=>Math.round(v*255).toString(16).padStart(2,'0');
    return '#'+to(f(h+1/3))+to(f(h))+to(f(h-1/3)); };
  return { bg: hsl(0.93,0.10), border: hsl(0.78,0.18) };
}

function A32Ring({ score, color }) {
  // 4px stroke on a 62px ring, cream interior, the score in mono. Smaller and quieter than the
  // team edition's gauge because the delta sits beside it rather than under it.
  const r=42, c=2*Math.PI*r, pct=Math.max(0,Math.min(100, score||0)), dash=c*pct/100;
  return (
    <svg width="62" height="62" viewBox="0 0 100 100" style={{ display:'block' }}>
      <circle cx="50" cy="50" r={r-2.85} fill="#FBF7F1" />
      <circle cx="50" cy="50" r={r} fill="none" stroke={A32.ringTrack} strokeWidth="5.7" />
      <circle cx="50" cy="50" r={r} fill="none" stroke={color} strokeWidth="5.7" strokeLinecap="round"
        strokeDasharray={`${dash} ${c}`} transform="rotate(-90 50 50)" />
      <text x="50" y="51" textAnchor="middle" dominantBaseline="middle"
        style={{ fontFamily:NL.mono, fontWeight:600, fontSize:fs(33), fill:NL.ink }}>
        {score==null?'—':score}</text>
    </svg>
  );
}

function A32SentimentCell({ cell, mode, isTop }) {
  const k=(cell.key||'').toUpperCase(), col=textSafe(k), tc=teamColor(k);
  const name=((window.SYNTH_TEAM_NAMES||{})[k]||{}).name || k;
  const label = mode==='movers' ? (isTop?'Biggest riser':'Biggest faller')
                                : (isTop?'Highest sentiment':'Lowest sentiment');
  const value = mode==='movers'
    ? `${isTop?'▲':'▼'} ${cell.delta>0?'+':''}${Number(cell.delta).toFixed(1)}`
    : String(cell.score);
  const sub = mode==='movers' ? '24-hour change' : 'Fan sentiment score';
  const aspects = cell.aspects||[];
  return (
    <div style={{ background:'#fff', border:`1px solid ${A32.hair}`, borderRadius:14, overflow:'hidden', boxSizing:'border-box' }}>
      <div style={{ height:5, background:tc.p }} />
      <div style={{ padding:'14px 16px 16px' }}>
        <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
          <span style={{ fontFamily:NL.mono, fontSize:fs(9.5), fontWeight:600, letterSpacing:'.16em', textTransform:'uppercase', color:col }}>{label}</span>
          <span style={{ fontFamily:NL.mono, fontSize:fs(9.5), letterSpacing:'.1em', textTransform:'uppercase', color:NL.ink3 }}>{k} · {name}</span>
        </div>
        <div style={{ display:'flex', alignItems:'center', marginTop:12 }}>
          <A32Ring score={cell.score} color={tc.p} />
          <div style={{ marginLeft:12 }}>
            <div style={{ fontFamily:NL.mono, fontSize:fs(15), fontWeight:600, color:isTop?A32.positive:A32.negative }}>{value}</div>
            <div style={{ fontFamily:NL.mono, fontSize:fs(9), letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3, marginTop:3 }}>{sub}</div>
          </div>
        </div>
        {aspects.length ? (<div>
          <div style={{ borderTop:`1px solid ${A32.hair}`, marginTop:15, paddingTop:12, fontFamily:NL.mono, fontSize:fs(9), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink }}>Top aspects</div>
          <table style={{ width:'100%', borderCollapse:'collapse', marginTop:6 }}><tbody>
            {aspects.slice(0,3).map((a,i)=>(
              <tr key={i}>
                <td width="14" valign="top" style={{ padding:'7px 0', fontFamily:NL.mono, fontSize:fs(11), color:col }}>{i+1}</td>
                <td style={{ padding:'7px 0', fontFamily:NL.sans, fontSize:fs(13), lineHeight:1.4, color:NL.ink2, borderBottom: i===Math.min(2,aspects.length-1)?'0':`1px solid ${A32.hairIn}` }}>{inlineMd(a)}</td>
              </tr>))}
          </tbody></table></div>) : null}
      </div>
    </div>
  );
}

function A32Sentiment({ pair }) {
  if(!pair) return null;
  return (
    <div style={{ maxWidth:602, margin:'0 auto 18px', display:'flex', gap:12, flexWrap:'wrap' }}>
      <div style={{ flex:'1 1 240px', minWidth:0 }}><A32SentimentCell cell={pair.top} mode={pair.mode} isTop={true} /></div>
      <div style={{ flex:'1 1 240px', minWidth:0 }}><A32SentimentCell cell={pair.bottom} mode={pair.mode} isTop={false} /></div>
    </div>
  );
}

function A32Puck({ k, name, record }) {
  const p=puck(k);
  return (
    <div style={{ width:140, textAlign:'center' }}>
      <div style={{ width:44, height:44, borderRadius:'50%', background:p.fill, margin:'0 auto', textAlign:'center' }}>
        <div style={{ fontFamily:NL.mono, fontSize:fs(12), fontWeight:600, letterSpacing:'.06em', color:p.ink, lineHeight:'44px' }}>{k}</div>
      </div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.12em', textTransform:'uppercase', color:NL.ink, marginTop:7 }}>{name}</div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), color:NL.ink3, marginTop:2 }}>{record||''}</div>
    </div>
  );
}

function marketLine(b, homeKey, awayKey){
  if(!b) return '';
  const out=[];
  if(typeof b.home_spread==='number'){ const fav=b.home_spread<=0?homeKey:awayKey;
    const n=b.home_spread<=0?b.home_spread:-b.home_spread; out.push(`${fav} ${n>0?'+':''}${n}`); }
  if(typeof b.over_under==='number') out.push(`O/U ${b.over_under}`);
  return out.join(' · ');
}

function A32GameCard({ slot, records, names }) {
  const isResult = slot.state==='results';
  const a=slot.away||{}, h=slot.home||{};
  const ak=(a.key||'').toUpperCase(), hk=(h.key||'').toUpperCase();
  const nm=k=>names[k]||((window.SYNTH_TEAM_NAMES||{})[k]||{}).name||k;
  let centre;
  if(isResult){
    const awayWins=(a.score||0)>(h.score||0), tie=(a.score||0)===(h.score||0);
    centre = (<div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3 }}>
        FINAL · {slot.overtime?'OT · ':''}WEEK {slot.week}</div>
      <div style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(44), letterSpacing:'-.025em', lineHeight:1.02, marginTop:2 }}>
        <span style={{ color:(awayWins||tie)?teamColor(ak).p:A32.scoreMuted }}>{a.score}</span>
        <span style={{ color:A32.scoreDash }}> – </span>
        <span style={{ color:(!awayWins||tie)?teamColor(hk).p:A32.scoreMuted }}>{h.score}</span>
      </div></div>);
  } else {
    centre = (<div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3 }}>{slot.day}</div>
      <div style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(23), letterSpacing:'-.02em', color:NL.ink, marginTop:3 }}>{slot.kickoff}</div>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.1em', color:NL.ink3, marginTop:4 }}>{slot.network||''}</div>
    </div>);
  }
  const market=isResult?'':marketLine(slot.betting, hk, ak);
  return (
    <div style={{ maxWidth:602, margin:'0 auto 12px', background:'#fff', border:`1px solid ${A32.hair}`, borderRadius:14, overflow:'hidden', boxSizing:'border-box' }}>
      <div style={{ padding: isResult?'14px 16px 8px':'15px 16px 9px', display:'flex', alignItems:'center', justifyContent:'space-between' }}>
        <A32Puck k={ak} name={nm(ak)} record={records[ak]} />
        <div style={{ flex:1, textAlign:'center', minWidth:0 }}>{centre}</div>
        <A32Puck k={hk} name={nm(hk)} record={records[hk]} />
      </div>
      {isResult && (slot.performers||[]).length ? (
        <div style={{ borderTop:`1px solid ${A32.hairIn}`, padding:'8px 16px 10px' }}>
          <table style={{ width:'100%', borderCollapse:'collapse' }}><tbody>
            {slot.performers.map((p,i)=>{
              const bb = i===slot.performers.length-1?'0':`1px solid ${A32.hairRow}`;
              return (<tr key={i}>
                <td width="34" style={{ padding:'4px 0', fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.08em', color:textSafe((p.team||'').toUpperCase()), borderBottom:bb }}>{p.team}</td>
                <td width="28" style={{ padding:'4px 0', fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.08em', color:NL.ink3, borderBottom:bb }}>{p.position}</td>
                <td style={{ padding:'4px 8px', fontFamily:NL.sans, fontSize:fs(13), color:NL.ink, borderBottom:bb }}>{p.name}</td>
                <td align="right" style={{ padding:'4px 0', fontFamily:NL.mono, fontSize:fs(11.5), color:NL.ink2, borderBottom:bb, whiteSpace:'nowrap' }}>{p.line}</td>
              </tr>);})}
          </tbody></table></div>) : null}
      {!isResult ? (
        <div style={{ borderTop:`1px solid ${A32.hairIn}`, padding:'10px 16px', textAlign:'center', fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.1em', textTransform:'uppercase', color:NL.ink3 }}>
          {slot.venue||''}{slot.venue&&market?'  ·  ':''}
          {market?<span style={{ color:NL.ink, fontWeight:600 }}>{market}</span>:null}
        </div>) : null}
    </div>
  );
}

function A32MergedCard({ group, dateLabel }) {
  const k=(group.team||'').toUpperCase(), tc=teamColor(k), col=tc.p, txt=textSafe(k);
  const tint=bandTint(k), story=group.story, band=group.band;
  const chart = band && band.chart ? band.chart : (story && story.chart ? story.chart : null);
  return (
    <div style={{ maxWidth:602, margin:'0 auto 18px', background:'#fff', border:`2px solid ${col}`, borderRadius:14, overflow:'hidden', boxSizing:'border-box' }}>
      <div style={{ height:7, background:col }} />
      {story ? (
        <div style={{ padding:'18px 26px 20px' }}>
          <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center' }}>
            <span style={{ display:'inline-block', background:col, borderRadius:3, padding:'4px 9px', fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.14em', textTransform:'uppercase', color:'#fff' }}>{story.type}</span>
            <span style={{ fontFamily:NL.mono, fontSize:fs(11), fontWeight:600, letterSpacing:'.1em', textTransform:'uppercase', color:txt }}>{group.label}</span>
          </div>
          {story.headline ? (<h3 style={{ margin:'14px 0 0', fontFamily:NL.serif, fontWeight:600, fontSize:fs(25), lineHeight:1.16, letterSpacing:'-.025em', color:NL.ink }}>{inlineMd(story.headline)}</h3>) : null}
          {(story.blocks||[]).map((b,i)=>(<Block key={i} block={b} accent={col} />))}
        </div>) : null}
      {band ? (
        <div style={{ background:tint.bg, borderTop:`1px solid ${tint.border}` }}>
          <div style={{ padding:'12px 26px 11px', borderBottom:`1px solid ${tint.border}`, fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.16em', textTransform:'uppercase', color:txt }}>{band.label}</div>
          <div style={{ padding:'16px 26px 20px' }}>
            {band.headline ? (<h4 style={{ margin:0, fontFamily:NL.sans, fontWeight:600, fontSize:fs(17), lineHeight:1.38, letterSpacing:'-.012em', color:NL.ink }}>{inlineMd(band.headline)}</h4>) : null}
            {(band.paras||[]).map((t,i)=>(<p key={i} style={{ margin:'10px 0 0', fontFamily:NL.sans, fontSize:fs(14.5), lineHeight:1.62, color:NL.ink2 }}>{inlineMd(t)}</p>))}
            {chart && chart.src ? (
              <div style={{ marginTop:14, background:'#fff', border:`1px solid ${tint.border}`, padding:'14px 16px 10px' }}>
                <div style={{ fontFamily:NL.mono, fontSize:fs(10), letterSpacing:'.14em', textTransform:'uppercase', color:NL.ink3 }}>{inlineMd(chart.title||'')}</div>
                {chart.subtitle ? (<div style={{ fontFamily:NL.mono, fontSize:fs(10), color:NL.ink3, marginTop:3 }}>{inlineMd(chart.subtitle)}</div>) : null}
                <img src={chart.src} alt={chart.alt||''} style={{ width:'100%', maxWidth:486, height:'auto', display:'block', margin:'10px auto 0' }} />
                <div style={{ borderTop:`1px solid ${NL.rule}`, marginTop:10, paddingTop:8, display:'flex', justifyContent:'space-between' }}>
                  <span style={{ fontFamily:NL.mono, fontSize:fs(9.5), letterSpacing:'.14em', fontWeight:600, color:NL.ink }}>SYNTH</span>
                  <span style={{ fontFamily:NL.mono, fontSize:fs(9.5), color:NL.ink3 }}>{inlineMd(chart.source||'')}</span>
                </div>
              </div>) : null}
          </div>
        </div>) : null}
    </div>
  );
}

function A32Standings({ st }) {
  const conf = st && st.conferences;
  if(!conf) return null;
  const col=(name,rows)=>{
    const line=(<tr><td colSpan={4} style={{ borderTop:`1px solid ${A32.hair}`, borderBottom:`1px solid ${A32.hair}`, padding:'6px 0', fontFamily:NL.mono, fontSize:fs(9), fontWeight:600, letterSpacing:'.16em', textTransform:'uppercase', color:A32.negative }}>Playoff line</td></tr>);
    const row=(r,seeded)=>{ const p=puck(r.key);
      return (<tr key={r.key}>
        <td width="14" style={{ padding:'6px 0', fontFamily:NL.mono, fontSize:fs(10), color:NL.ink3 }}>{seeded?r.rank:'—'}</td>
        <td width="40" style={{ padding:'6px 4px 6px 0' }}>
          <span style={{ display:'inline-block', background:p.fill, borderRadius:999, padding:'3px 6px', fontFamily:NL.mono, fontSize:fs(9.5), fontWeight:600, letterSpacing:'.08em', color:p.ink }}>{r.key}</span></td>
        <td style={{ padding:'6px 0', fontFamily:NL.sans, fontSize:fs(12.5), color:NL.ink, whiteSpace:'nowrap' }}>{r.name}</td>
        <td align="right" style={{ padding:'6px 0', fontFamily:NL.mono, fontSize:fs(11), color:NL.ink2, whiteSpace:'nowrap' }}>
          {r.record}{!seeded && r.games_back!=null ? <span style={{ color:NL.ink3, marginLeft:6 }}>{r.games_back} GB</span> : null}</td>
      </tr>); };
    return (<div style={{ flex:1, minWidth:0 }}>
      <div style={{ fontFamily:NL.mono, fontSize:fs(10), fontWeight:600, letterSpacing:'.18em', textTransform:'uppercase', color:NL.ink3, paddingBottom:7, borderBottom:`1px solid ${A32.hair}`, marginBottom:4 }}>{name}</div>
      <table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}><tbody>
        {(rows.seeded||[]).map(r=>row(r,true))}
        {rows.playoff_line?line:null}
        {(rows.out||[]).map(r=>row(r,false))}
      </tbody></table></div>);
  };
  return (<div>
    <div style={{ maxWidth:602, margin:'26px auto 0', padding:'0 26px', boxSizing:'border-box' }}>
      <div style={{ height:3, background:NL.ink, borderRadius:2, marginBottom:12 }} />
      <div style={{ fontFamily:NL.mono, fontSize:fs(10.5), fontWeight:600, letterSpacing:'.18em', textTransform:'uppercase', color:NL.ink }}>Around the league</div>
      <div style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(25), letterSpacing:'-.025em', color:NL.ink, marginTop:8 }}>League standings through Week {st.as_of_week!=null?st.as_of_week:'—'}</div>
    </div>
    <div style={{ maxWidth:602, margin:'0 auto 18px', background:'#fff', border:`1px solid ${A32.hair}`, borderRadius:14, overflow:'hidden', boxSizing:'border-box', padding:'16px 16px 14px' }}>
      <div style={{ display:'flex', gap:20, flexWrap:'wrap' }}>
        {col('AFC', conf.AFC||{})}
        {col('NFC', conf.NFC||{})}
      </div>
      <div style={{ borderTop:`1px solid ${A32.hairIn}`, marginTop:12, paddingTop:9, fontFamily:NL.mono, fontSize:fs(9.5), letterSpacing:'.1em', color:NL.ink3 }}>NFL standings · through {st.as_of_date}</div>
    </div>
  </div>);
}

function LeagueEdition({ issue, exportMode }) {
  const m = issue.masthead || {};
  const nl = issue.newsletter || {};
  const records = issue.records || {};
  const names = {};
  // export mode wraps the whole edition in ONE [data-card] anchor (the --render image path
  // screenshots it as a single PNG).
  const Wrap = exportMode
    ? ({children}) => (<div data-card="league">{children}</div>)
    : ({children}) => (<React.Fragment>{children}</React.Fragment>);
  const dateLabel = (m.dateLine||'').split(' · ').pop();
  const merged = issue.merged && issue.merged.length ? issue.merged : null;
  return (
   <Wrap>
    <div style={NL.scaffold}>
      {/* masthead: solid navy block */}
      <div style={{ maxWidth:602, margin:'0 auto 20px', background:NL.ink, borderRadius:14, overflow:'hidden', boxSizing:'border-box' }}>
        <div style={{ padding:'22px 26px 20px' }}>
          <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
            <span style={{ fontFamily:NL.serif, fontWeight:700, fontSize:fs(42), letterSpacing:'-.035em', color:'#FBF7F1', lineHeight:1 }}>Synth</span>
            <span style={{ fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.1em', textTransform:'uppercase', color:A32.mastMeta }}>{m.dateLine}</span>
          </div>
          <div style={{ borderTop:`1px solid ${A32.mastRule}`, marginTop:16, paddingTop:12, fontFamily:NL.mono, fontSize:fs(11.5), fontWeight:600, letterSpacing:'.2em', textTransform:'uppercase', color:A32.mastKicker }}>
            {m.kicker || 'All 32'}</div>
        </div>
      </div>
      {issue.opening && (
        <div style={{ maxWidth:602, margin:'0 auto 20px', padding:'0 26px', boxSizing:'border-box' }}>
          <p style={{ margin:0, fontFamily:NL.serif, fontSize:fs(20), lineHeight:1.5, color:NL.ink }}>{inlineMd(issue.opening)}</p>
        </div>)}
      <A32Sentiment pair={issue.sentiment_pair} />
      {(issue.slate||[]).map((s,i)=>(<A32GameCard key={i} slot={s} records={records} names={names} />))}
      {(issue.slate||[]).length ? <div style={{ height:18 }} /> : null}
      {merged
        ? merged.map((g,i)=>(<A32MergedCard key={i} group={g} dateLabel={dateLabel} />))
        : (<NewsletterBody issue={issue} />)}
      <A32Standings st={issue.standings} />
      <NLFooter />
    </div>
   </Wrap>
  );
}

window.SynthLeagueEdition = LeagueEdition;

function NewslettersPage() {
  const realTeams = window.SYNTH_ISSUE_TEAMS || ['LAR','NYG'];
  const datesByTeam = window.SYNTH_ISSUE_DATES_BY_TEAM || {};
  const allDates = window.SYNTH_ISSUE_DATES || [];
  const leagueEditions = window.SYNTH_LEAGUE_EDITIONS || {};
  const leagueDates = (window.SYNTH_LEAGUE_DATES || []).slice().reverse();  // newest first
  const hasLeague = leagueDates.length > 0;
  // The League option leads the dropdown (when any league edition exists), then the 32 teams.
  const teams = hasLeague ? [LEAGUE_KEY, ...realTeams] : realTeams;
  const [team, setTeam] = React.useState(teams[0]);
  const isLeague = team === LEAGUE_KEY;
  // dates available for the current selection: league dates for the League option, else the team's
  const dates = isLeague ? leagueDates : (datesByTeam[team] || allDates);
  const [date, setDate] = React.useState(dates[0]);
  // when the team changes, snap the date to that selection's first available date
  React.useEffect(() => {
    if (dates.length && !dates.includes(date)) setDate(dates[0]);
  }, [team]);
  const issue = isLeague ? leagueEditions[date] : (window.SYNTH_ISSUES || {})[team + '_' + date];
  const nameOf = (t) => {
    if (t === LEAGUE_KEY) return 'League · All 32';
    const n=(window.SYNTH_TEAM_NAMES||{})[t]; return n ? `${n.city} ${n.name}` : t;
  };
  const labelDate = (d) => new Date(d+'T12:00:00').toLocaleDateString('en-US',{weekday:'short', month:'short', day:'numeric', year:'numeric'});

  const sel = {
    fontFamily:NL.sans, fontSize:fs(15), color:NL.ink, background:'#FFFFFF',
    border:'2px solid var(--syn-border-strong, #C9BFA8)', borderRadius:10,
    padding:'11px 14px', outline:'none', cursor:'pointer', minWidth:200,
  };
  const lbl = { fontFamily:NL.mono, fontSize:fs(11), letterSpacing:'.12em', textTransform:'uppercase', color:NL.ink3, display:'block', marginBottom:7 };

  return (
    <main style={{ background:'var(--syn-paper, #FBF7F1)', minHeight:'calc(100vh - 64px)' }}>
      <div style={{ maxWidth:1120, margin:'0 auto', padding:'56px 48px 24px' }}>
        <div style={{ fontFamily:NL.mono, fontSize:fs(12), letterSpacing:'.16em', textTransform:'uppercase', color:NL.ink3, fontWeight:500, marginBottom:16 }}>Newsletters</div>
        <h1 style={{ fontFamily:NL.serif, fontSize:fs(44), fontWeight:600, letterSpacing:'-0.03em', lineHeight:1.04, color:NL.ink, margin:'0 0 10px' }}>The daily issue, by team and date.</h1>
        <p style={{ fontFamily:NL.serif, fontSize:fs(18), lineHeight:1.45, color:'#4A4A4A', maxWidth:640, margin:'0 0 32px', fontWeight:400 }}>Pick a team and a date to read that day's Synth issue.</p>
        <div style={{ display:'flex', gap:20, flexWrap:'wrap', alignItems:'flex-end' }}>
          <div>
            <label style={lbl}>Team</label>
            <select value={team} onChange={e=>setTeam(e.target.value)} style={sel}>
              {teams.map(t=>(<option key={t} value={t}>{nameOf(t)}</option>))}
            </select>
          </div>
          <div>
            <label style={lbl}>Date</label>
            <select value={date} onChange={e=>setDate(e.target.value)} style={sel}>
              {dates.map(d=>(<option key={d} value={d}>{labelDate(d)}</option>))}
            </select>
          </div>
        </div>
      </div>
      <div style={{ borderTop:`1px solid ${NL.rule}`, padding:'32px 16px 96px' }}>
        {issue
          ? (isLeague ? <LeagueEdition issue={issue} /> : <Scaffolding issue={issue} />)
          : <div style={{ textAlign:'center', fontFamily:NL.mono, color:NL.ink3, padding:'60px 0' }}>
              {isLeague
                ? 'No front page for this date — the league edition runs after the team newsletters.'
                : `No issue found for ${nameOf(team)} · ${labelDate(date)}.`}
            </div>}
      </div>
    </main>
  );
}
window.NewslettersPage = NewslettersPage;
