// features.jsx — new interactive features

// ─────────────────────────────────────────────────────────────
// SunlitGlass — stained-glass with live sun position
// ─────────────────────────────────────────────────────────────
function SunlitGlass({ theme, height = 220, seed = 1 }) {
  const [now, setNow] = React.useState(new Date());
  React.useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 60000);
    return () => clearInterval(id);
  }, []);
  // map 6am–8pm to 0..1
  const h = now.getHours() + now.getMinutes() / 60;
  const dayPos = Math.max(0, Math.min(1, (h - 6) / 14));
  const isNight = h < 6 || h > 20;
  const sunX = 50 + (dayPos - 0.5) * 260; // sweep across
  const sunY = 110 - Math.sin(dayPos * Math.PI) * 80; // arc
  const intensity = isNight ? 0.15 : (1 - Math.abs(dayPos - 0.5) * 1.2);
  const palette = [theme.accent, theme.gold, '#3a5a7a', '#3a2858', '#3a5a3a'];

  // Tall Gothic lancet with abstract Celtic cross — lit by the sun
  const stoneStroke = theme.ink;
  const arch = "M40 240 L40 110 Q40 28 100 14 Q160 28 160 110 L160 240 Z";
  // Sun-driven brightness falloff across the field
  const sunBrightness = isNight ? 0 : Math.max(0, 1 - Math.abs(100 - sunX) / 110) * intensity;
  return (
    <div style={{ width: '100%', height, position: 'relative', overflow: 'hidden', background: theme.bgAlt, borderTop: `1px solid ${theme.rule}`, borderBottom: `1px solid ${theme.rule}` }}>
      <svg viewBox="0 0 200 240" preserveAspectRatio="xMidYMid meet" width="100%" height="100%" style={{ display: 'block' }}>
        <defs>
          <radialGradient id={`sun-${seed}`} cx="0.5" cy="0.5" r="0.5">
            <stop offset="0%" stopColor={isNight ? '#cfd8e8' : '#fff5d8'} stopOpacity={isNight ? 0.45 : 0.95}/>
            <stop offset="60%" stopColor={isNight ? '#cfd8e8' : '#fff5d8'} stopOpacity="0"/>
          </radialGradient>
          <clipPath id={`lancet-sun-${seed}`}>
            <path d={arch}/>
          </clipPath>
          <radialGradient id={`bg-sun-${seed}`} cx="0.5" cy="0.55" r="0.65">
            <stop offset="0%" stopColor={palette[(seed + 1) % palette.length]} stopOpacity="0.85"/>
            <stop offset="100%" stopColor={palette[(seed + 3) % palette.length]} stopOpacity="0.95"/>
          </radialGradient>
        </defs>

        {/* sun halo behind glass */}
        <circle cx={sunX} cy={sunY + 30} r="100" fill={`url(#sun-${seed})`} opacity={isNight ? 0.7 : 1}/>

        {/* stone arch */}
        <path d={arch} fill={theme.bgAlt} stroke={stoneStroke} strokeOpacity="0.55" strokeWidth="3"/>

        <g clipPath={`url(#lancet-sun-${seed})`}>
          {/* Background glass with sun-driven opacity */}
          <rect x="0" y="0" width="200" height="240" fill={`url(#bg-sun-${seed})`} opacity={isNight ? 0.55 : 0.7 + sunBrightness * 0.3}/>

          {/* Light bloom across the field */}
          {!isNight && sunBrightness > 0.1 && (
            <rect x="0" y="0" width="200" height="240" fill="#fff5d8" opacity={sunBrightness * 0.35}/>
          )}

          {/* Lead came lattice */}
          {(() => {
            const lines = [];
            for (let y = 30; y <= 240; y += 22) {
              lines.push(<line key={`h${y}`} x1="20" y1={y} x2="180" y2={y} stroke={stoneStroke} strokeOpacity="0.18" strokeWidth="0.6"/>);
            }
            for (let x = 50; x <= 150; x += 22) {
              lines.push(<line key={`v${x}`} x1={x} y1="20" x2={x} y2="240" stroke={stoneStroke} strokeOpacity="0.18" strokeWidth="0.6"/>);
            }
            return lines;
          })()}

          {/* Celtic cross — nimbus halo */}
          <circle cx="100" cy="118" r="34" fill={theme.gold} opacity={isNight ? 0.35 : 0.42 + sunBrightness * 0.3}/>
          <circle cx="100" cy="118" r="34" fill="none" stroke={stoneStroke} strokeOpacity="0.55" strokeWidth="3"/>
          <circle cx="100" cy="118" r="27" fill="none" stroke={stoneStroke} strokeOpacity="0.4" strokeWidth="1.2"/>

          {/* Vertical bar */}
          <rect x="92" y="58" width="16" height="158" fill={palette[seed % palette.length]} opacity={isNight ? 0.6 : 0.92}
                stroke={stoneStroke} strokeOpacity="0.6" strokeWidth="2"/>
          {/* Horizontal crossbar */}
          <rect x="56" y="110" width="88" height="16" fill={palette[seed % palette.length]} opacity={isNight ? 0.6 : 0.92}
                stroke={stoneStroke} strokeOpacity="0.6" strokeWidth="2"/>

          {/* Center jewel */}
          <circle cx="100" cy="118" r="6" fill={theme.gold} stroke={stoneStroke} strokeOpacity="0.6" strokeWidth="1.2"/>

          {/* Small jewels along the cross arms */}
          <circle cx="100" cy="78" r="3" fill={theme.gold} stroke={stoneStroke} strokeOpacity="0.5" strokeWidth="0.8"/>
          <circle cx="100" cy="200" r="3" fill={theme.gold} stroke={stoneStroke} strokeOpacity="0.5" strokeWidth="0.8"/>
          <circle cx="68" cy="118" r="3" fill={theme.gold} stroke={stoneStroke} strokeOpacity="0.5" strokeWidth="0.8"/>
          <circle cx="132" cy="118" r="3" fill={theme.gold} stroke={stoneStroke} strokeOpacity="0.5" strokeWidth="0.8"/>
        </g>

        <path d={arch} fill="none" stroke={stoneStroke} strokeOpacity="0.55" strokeWidth="2"/>
      </svg>
      <div style={{ position: 'absolute', top: 8, left: 12, ...smallCaps(theme, 8), color: theme.inkMute, fontFamily: theme.fonts.mono }}>
        {isNight ? '✦ vigil light' : '☀ ' + now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// DailyOfficeCard — small widget showing next office + countdown
// ─────────────────────────────────────────────────────────────
function DailyOfficeCard({ theme, onOpen }) {
  const [now, setNow] = React.useState(new Date());
  React.useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 30000);
    return () => clearInterval(id);
  }, []);
  const next = nextOffice(now);
  const cur = currentOffice(now);
  const hrs = Math.floor(next.when / 60);
  const mins = next.when % 60;
  const offices = OFFICES;
  const minsNow = now.getHours() * 60 + now.getMinutes();
  return (
    <div style={{ padding: '12px 22px 0' }}>
      <button onClick={onOpen} style={{ width: '100%', background: theme.paper, border: `1px solid ${theme.rule}`, padding: 16, cursor: 'pointer', textAlign: 'left' }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 10 }}>
          <span style={{ ...smallCaps(theme, 9), color: theme.gold }}>The Daily Office</span>
          <span style={{ ...smallCaps(theme, 9), color: theme.inkMute }}>now: {cur.alt}</span>
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 22, color: theme.ink, fontWeight: 500, lineHeight: 1.1 }}>
          <span style={{ fontStyle: 'italic', color: theme.accent }}>{next.alt}</span> <span style={{ color: theme.inkSoft }}>begins in</span>
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 32, color: theme.ink, fontWeight: 500, marginTop: 4 }}>
          {hrs > 0 && <>{hrs}<span style={{ ...smallCaps(theme, 11), color: theme.inkMute, marginLeft: 3 }}>h</span> </>}
          {String(mins).padStart(2, '0')}<span style={{ ...smallCaps(theme, 11), color: theme.inkMute, marginLeft: 3 }}>m</span>
        </div>
        {/* timeline of offices */}
        <div style={{ position: 'relative', marginTop: 14, height: 26 }}>
          <div style={{ position: 'absolute', top: 11, left: 0, right: 0, height: 1, background: theme.rule }}/>
          {offices.map((o, i) => {
            const pct = (o.hour * 60 + o.minute) / (24 * 60) * 100;
            const passed = (o.hour * 60 + o.minute) <= minsNow;
            return (
              <div key={o.id} style={{ position: 'absolute', left: pct + '%', top: 0, transform: 'translateX(-50%)', textAlign: 'center' }}>
                <div style={{ width: 7, height: 7, borderRadius: '50%', background: passed ? theme.gold : theme.bgAlt, border: `1px solid ${theme.gold}`, margin: '8px auto 0' }}/>
                <div style={{ ...smallCaps(theme, 7), color: theme.inkMute, marginTop: 2, fontSize: 7 }}>{o.alt.slice(0, 3)}</div>
              </div>
            );
          })}
          <div style={{ position: 'absolute', left: minsNow / (24 * 60) * 100 + '%', top: 6, width: 1, height: 16, background: theme.accent }}/>
        </div>
      </button>
    </div>
  );
}

// Modal showing the current office
function OfficeModal({ theme, onClose }) {
  const cur = currentOffice();
  const c = OFFICE_CONTENT[cur.id] || OFFICE_CONTENT.compline;
  React.useEffect(() => { try { playSound('page-turn'); } catch(e){} }, []);
  return (
    <ModalShell theme={theme} onClose={onClose} title={cur.name} subtitle={cur.alt}>
      <div style={{ padding: '18px 22px' }}>
        <MissalRule theme={theme} label="Versicle" />
        <div style={{ fontFamily: theme.fonts.display, fontSize: 19, color: theme.ink, fontStyle: 'italic', lineHeight: 1.4 }}>
          ℣  {c.versicle}
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 19, color: theme.accent, fontStyle: 'italic', lineHeight: 1.4, marginTop: 6 }}>
          ℟  {c.response}
        </div>
        <MissalRule theme={theme} label="Psalmody" />
        <div style={{ fontFamily: theme.fonts.display, fontSize: 17, color: theme.ink }}>{c.psalm}</div>
        <MissalRule theme={theme} label="Canticle" />
        <div style={{ fontFamily: theme.fonts.display, fontSize: 17, color: theme.ink }}>{c.canticle}</div>
        <MissalRule theme={theme} label="" />
        <div style={{ fontFamily: theme.fonts.display, fontStyle: 'italic', fontSize: 14, color: theme.inkSoft, lineHeight: 1.5 }}>
          The grace of our Lord Jesus Christ, and the love of God, and the fellowship of the Holy Spirit, be with us all evermore.
          <span style={{ ...smallCaps(theme, 10), color: theme.gold, fontStyle: 'normal', display: 'block', marginTop: 6 }}>Amen.</span>
        </div>
      </div>
    </ModalShell>
  );
}

// ─────────────────────────────────────────────────────────────
// LectionaryModal — full readings as a missal page
// ─────────────────────────────────────────────────────────────
function LectionaryModal({ theme, onClose, reading }) {
  const r = LECTIONARY_FULL[reading] || LECTIONARY_FULL.gospel;
  return (
    <ModalShell theme={theme} onClose={onClose} title={r.title} subtitle={r.citation}>
      <div style={{ padding: '18px 22px', background: theme.paper }}>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 16, lineHeight: 1.65, color: theme.ink, fontWeight: 400 }}>
          {r.body.map((p, i) => (
            <p key={i} style={{ margin: '0 0 14px', textIndent: i === 0 ? 0 : 18 }}>
              {i === 0 && <DropCap letter={p.charAt(0)} theme={theme} size={48} />}
              {i === 0 ? p.slice(1) : p}
            </p>
          ))}
        </div>
        <MissalRule theme={theme} label="Hear what the Spirit is saying" />
        <div style={{ display: 'flex', gap: 8 }}>
          <button style={{ flex: 1, padding: '12px', background: theme.ink, color: theme.paper, border: 'none', ...smallCaps(theme, 10), cursor: 'pointer' }}>Send to Bible app</button>
          <button onClick={() => softChime(660)} style={{ flex: 1, padding: '12px', background: 'transparent', color: theme.ink, border: `1px solid ${theme.ink}`, ...smallCaps(theme, 10), cursor: 'pointer' }}>♪ Listen</button>
        </div>
      </div>
    </ModalShell>
  );
}

// ─────────────────────────────────────────────────────────────
// SaintRibbon — slim ribbon between season & hero
// ─────────────────────────────────────────────────────────────
function SaintRibbon({ theme }) {
  const s = saintToday();
  if (!s) return null;
  // The saint name links out to a biographical page (Franciscan Media for
  // most days, Wikipedia for the Wesleys and Epiphany). Underline only on
  // hover so the ribbon stays calm visually; the cursor change makes the
  // affordance obvious.
  const nameNode = s.link ? (
    <a
      href={s.link}
      target="_blank"
      rel="noopener noreferrer"
      style={{ color: theme.accent, textDecoration: 'none', borderBottom: `1px dotted ${theme.accent}55` }}
      onMouseEnter={(e) => { e.currentTarget.style.borderBottomColor = theme.accent; }}
      onMouseLeave={(e) => { e.currentTarget.style.borderBottomColor = `${theme.accent}55`; }}
    >
      {s.name}
    </a>
  ) : (
    <span style={{ color: theme.accent }}>{s.name}</span>
  );

  return (
    <div style={{
      display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
      padding: '4px 18px',
      background: theme.paper, color: theme.gold,
      borderBottom: `1px solid ${theme.ruleSoft}`,
      ...smallCaps(theme, 9),
    }}>
      <span>★</span>
      <span style={{ color: theme.inkSoft, fontStyle: 'italic', textTransform: 'none', letterSpacing: 0, fontFamily: theme.fonts.display, fontSize: 13 }}>
        Today we remember
      </span>
      {nameNode}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// softChime — a brief gentle tone played between prayer lines.
// Respects the global soundEnabled flag (so toggling Sounds off in the
// AA panel silences these too) and registers as a "meaningful" sound
// so that any near-simultaneous page-turn yields and stays quiet.
function softChime(freq = 880) {
  try {
    // Respect the global sound toggle.
    let enabled = false;
    try {
      const s = JSON.parse(localStorage.getItem('mumc.a11y') || '{}');
      enabled = !!s.soundEnabled;
    } catch (e) {}
    if (!enabled) return;

    const Ctx = window.AudioContext || window.webkitAudioContext;
    if (!Ctx) return;
    if (!window.__chimeCtx) window.__chimeCtx = new Ctx();
    const ctx = window.__chimeCtx;
    if (ctx.state === 'suspended' && ctx.resume) { try { ctx.resume(); } catch (e) {} }
    const t = ctx.currentTime;
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = 'sine';
    osc.frequency.setValueAtTime(freq, t);
    gain.gain.setValueAtTime(0, t);
    gain.gain.linearRampToValueAtTime(0.06, t + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.0001, t + 1.0);
    osc.connect(gain).connect(ctx.destination);
    osc.start(t);
    osc.stop(t + 1.05);

    // Mark a meaningful sound so the ambient page-turn yields.
    try { _lastMeaningfulSoundAt = Date.now(); } catch (e) {}
  } catch (e) { /* silent — audio is a nice-to-have */ }
}

// ─────────────────────────────────────────────────────────────
// PdfViewerModal — in-page PDF viewer for bulletins (and any other
// PDF the site links to). Uses PDF.js, loaded from a CDN on first
// open. Renders each page as a canvas at the container's width,
// so trifold landscape pages still fit and stay readable. Pinch-zoom
// and panning work natively over the scroll surface.
// ─────────────────────────────────────────────────────────────
// PDF.js 4.x+ handles macOS Quartz PDFs more reliably than 3.x, but only ships
// as ES modules (.mjs). We bypass that by using dynamic import() — wrapped in
// new Function so Babel-standalone (which compiles our JSX in the browser)
// doesn't try to transform the import syntax and break it.
const PDFJS_VERSION = '4.10.38';
const PDFJS_BASE = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${PDFJS_VERSION}`;

function loadPdfJs() {
  if (window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
  if (window.__pdfjsLoading) return window.__pdfjsLoading;
  window.__pdfjsLoading = (async () => {
    try {
      const dynImport = new Function('u', 'return import(u)');
      const mod = await dynImport(`${PDFJS_BASE}/pdf.min.mjs`);
      const lib = mod && mod.getDocument ? mod : (mod && mod.default ? mod.default : mod);
      if (lib && lib.GlobalWorkerOptions) {
        lib.GlobalWorkerOptions.workerSrc = `${PDFJS_BASE}/pdf.worker.min.mjs`;
      }
      window.pdfjsLib = lib;
      return lib;
    } catch (err) {
      window.__pdfjsLoading = null; // allow retry on next open
      throw err;
    }
  })();
  return window.__pdfjsLoading;
}

function PdfViewerModal({ theme, url, title, onClose }) {
  const containerRef = React.useRef(null);
  const [status, setStatus] = React.useState('loading'); // loading | ready | error
  const [errMsg, setErrMsg] = React.useState('');
  const [zoom, setZoom] = React.useState(1);

  React.useEffect(() => {
    if (!url) return;
    let cancelled = false;
    setStatus('loading');
    setErrMsg('');

    (async () => {
      try {
        const pdfjs = await loadPdfJs();
        if (cancelled) return;

        const loadingTask = pdfjs.getDocument({ url });
        const doc = await loadingTask.promise;
        if (cancelled) return;

        const container = containerRef.current;
        if (!container) return;
        container.innerHTML = ''; // wipe any prior render

        // Render each page at a width that fits the modal column,
        // multiplied by the user-controlled zoom. devicePixelRatio
        // keeps text crisp on retina displays without ballooning size.
        const targetWidth = (container.clientWidth || 700) * zoom;
        const dpr = Math.min(2, window.devicePixelRatio || 1);

        for (let i = 1; i <= doc.numPages; i++) {
          if (cancelled) return;
          const page = await doc.getPage(i);
          const baseViewport = page.getViewport({ scale: 1 });
          const scale = (targetWidth / baseViewport.width) * dpr;
          const viewport = page.getViewport({ scale });

          const canvas = document.createElement('canvas');
          canvas.width = viewport.width;
          canvas.height = viewport.height;
          canvas.style.width = (viewport.width / dpr) + 'px';
          canvas.style.height = (viewport.height / dpr) + 'px';
          canvas.style.maxWidth = '100%';
          canvas.style.display = 'block';
          canvas.style.margin = '0 auto 14px';
          canvas.style.boxShadow = '0 2px 14px rgba(0,0,0,0.25)';
          canvas.style.background = '#ffffff';
          container.appendChild(canvas);

          await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
        }
        if (!cancelled) setStatus('ready');
      } catch (err) {
        if (cancelled) return;
        console.error('PDF render failed:', err);
        setErrMsg(String(err.message || err));
        setStatus('error');
      }
    })();

    return () => { cancelled = true; };
  }, [url, zoom]);

  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, zIndex: 250,
      background: 'rgba(20,16,10,0.92)',
      display: 'flex', flexDirection: 'column',
    }}>
      {/* Header */}
      <div onClick={(e) => e.stopPropagation()} style={{
        flexShrink: 0, padding: 'max(12px, env(safe-area-inset-top)) 14px 12px',
        background: theme.paper, borderBottom: `1px solid ${theme.rule}`,
        display: 'flex', alignItems: 'center', gap: 10,
      }}>
        <button onClick={onClose} style={{
          background: 'transparent', border: `1px solid ${theme.rule}`,
          ...smallCaps(theme, 9), color: theme.ink, padding: '8px 12px', cursor: 'pointer',
        }}>‹ Close</button>
        <div style={{ flex: 1, fontFamily: theme.fonts.display, fontSize: 15, fontStyle: 'italic', color: theme.ink, lineHeight: 1.2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {title || 'Bulletin'}
        </div>
        <button onClick={() => setZoom(z => Math.max(0.6, z - 0.2))} aria-label="Zoom out" style={{
          background: 'transparent', border: `1px solid ${theme.rule}`,
          color: theme.ink, padding: '6px 10px', cursor: 'pointer', fontSize: 16, lineHeight: 1,
        }}>−</button>
        <button onClick={() => setZoom(z => Math.min(3, z + 0.2))} aria-label="Zoom in" style={{
          background: 'transparent', border: `1px solid ${theme.rule}`,
          color: theme.ink, padding: '6px 10px', cursor: 'pointer', fontSize: 16, lineHeight: 1,
        }}>＋</button>
        <a href={url} download style={{
          ...smallCaps(theme, 9), color: theme.paper, background: theme.ink,
          textDecoration: 'none', padding: '8px 12px', border: 'none',
        }}>↓ Save</a>
      </div>

      {/* Render area */}
      <div onClick={(e) => e.stopPropagation()} style={{
        flex: 1, overflow: 'auto',
        padding: 12, background: 'rgba(20,16,10,0.95)',
      }}>
        {status === 'loading' && (
          <div style={{ color: theme.paper, textAlign: 'center', fontFamily: theme.fonts.display, fontStyle: 'italic', padding: 40, opacity: 0.85 }}>
            Loading bulletin…
          </div>
        )}
        {status === 'error' && (
          <div style={{ color: theme.paper, textAlign: 'center', fontFamily: theme.fonts.body, fontStyle: 'italic', padding: 40, lineHeight: 1.5 }}>
            Couldn't load the bulletin in this view.<br/>
            <a href={url} target="_blank" rel="noopener noreferrer" style={{ color: theme.gold }}>
              Open the PDF directly →
            </a>
            {errMsg && <div style={{ ...smallCaps(theme, 8), color: theme.gold, marginTop: 12, opacity: 0.7 }}>{errMsg}</div>}
          </div>
        )}
        <div ref={containerRef} />
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// PrayWithUs — Lord's Prayer at a meditative cadence, then this
// week's Prayer of the Day, then close.
// ─────────────────────────────────────────────────────────────
const LORDS_PRAYER = [
  'Our Father, who art in heaven,',
  'hallowed be thy Name.',
  'Thy kingdom come,',
  'thy will be done,',
  'on earth as it is in heaven.',
  'Give us this day our daily bread.',
  'And forgive us our trespasses,',
  'as we forgive those who trespass against us.',
  'And lead us not into temptation,',
  'but deliver us from evil.',
  'For thine is the kingdom,',
  'and the power, and the glory,',
  'for ever and ever.',
  'Amen.',
];

function PrayWithUsModal({ theme, onClose }) {
  const [phase, setPhase] = React.useState('lords'); // 'lords' | 'collect'
  const [idx, setIdx] = React.useState(0);
  const [paused, setPaused] = React.useState(false);
  // Note: softChime() on mount (below) already provides the audio cue
  // when the modal opens — no page-turn needed here, that would stack.

  // Pull this week's Collect (the Prayer of the Day) — auto-changes by Sunday.
  const [collect] = React.useState(() => {
    try { return (typeof getCollect === 'function') ? getCollect() : null; } catch (e) { return null; }
  });

  // Auto-advance through the Lord's Prayer; on the last line, transition
  // to the Collect phase after a short pause.
  React.useEffect(() => {
    if (phase !== 'lords' || paused) return;
    if (idx >= LORDS_PRAYER.length - 1) {
      if (!collect || !collect.text) return;
      const t = setTimeout(() => setPhase('collect'), 4200);
      return () => clearTimeout(t);
    }
    const t = setTimeout(() => {
      softChime(idx % 2 === 0 ? 880 : 660);
      setIdx(i => i + 1);
    }, 3800);
    return () => clearTimeout(t);
  }, [idx, paused, phase, collect]);

  // Initial chime on mount (gracefully no-ops if AudioContext is blocked).
  React.useEffect(() => { softChime(880); }, []);

  // Soft chime on phase change to mark the transition.
  React.useEffect(() => { if (phase === 'collect') softChime(550); }, [phase]);

  const restart = () => { setPhase('lords'); setIdx(0); setPaused(false); };

  if (phase === 'collect' && collect) {
    return (
      <ModalShell theme={theme} onClose={onClose} title="Pray with us" subtitle="This Week's Prayer">
        <div style={{ padding: '40px 28px 60px', minHeight: 380, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', background: theme.paper, animation: 'fadein 0.8s ease both' }}>
          {collect.name && (
            <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 18 }}>
              {collect.name}
            </div>
          )}
          <div style={{
            fontFamily: theme.fonts.display, fontSize: 22, fontStyle: 'italic',
            color: theme.ink, lineHeight: 1.55, fontWeight: 400, maxWidth: 540,
          }}>
            {collect.text} <span style={{ ...smallCaps(theme, 11), color: theme.gold, fontStyle: 'normal' }}>Amen.</span>
          </div>
          <div style={{ marginTop: 36, display: 'flex', gap: 16 }}>
            <button onClick={restart} style={{ background: 'transparent', border: `1px solid ${theme.gold}`, color: theme.ink, ...smallCaps(theme, 10), padding: '8px 16px', cursor: 'pointer' }}>
              ↺ From the top
            </button>
            <button onClick={onClose} style={{ background: theme.ink, border: 'none', color: theme.paper, ...smallCaps(theme, 10), padding: '10px 18px', cursor: 'pointer' }}>
              ✓ Amen
            </button>
          </div>
        </div>
      </ModalShell>
    );
  }

  return (
    <ModalShell theme={theme} onClose={onClose} title="Pray with us" subtitle="The Lord's Prayer">
      <div style={{ padding: '40px 28px 60px', minHeight: 380, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', background: theme.paper }}>
        {LORDS_PRAYER.map((line, i) => {
          const dist = Math.abs(i - idx);
          if (dist > 2) return null;
          // Progressive blur on non-current lines so they recede behind the
          // line being prayed and stop competing visually. Current line is
          // crisp; adjacent line is softly out of focus; two-away is mostly
          // ghost. Combined with opacity dimming, the eye lands cleanly on
          // the present moment of the prayer.
          const blur = dist === 0 ? 0 : (dist === 1 ? 1.4 : 3.0);
          const opacity = dist === 0 ? 1 : (dist === 1 ? 0.45 : 0.18);
          return (
            <div key={i} style={{
              fontFamily: theme.fonts.display,
              fontSize: dist === 0 ? 28 : 16,
              fontStyle: 'italic',
              fontWeight: dist === 0 ? 500 : 400,
              color: dist === 0 ? theme.ink : theme.inkMute,
              opacity,
              filter: blur ? `blur(${blur}px)` : 'none',
              padding: '8px 0',
              transition: 'all 0.6s ease',
              lineHeight: 1.3,
            }}>{line}</div>
          );
        })}
        <div style={{ marginTop: 32, display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
          <button onClick={() => setPaused(p => !p)} style={{ background: 'transparent', border: `1px solid ${theme.gold}`, color: theme.ink, ...smallCaps(theme, 10), padding: '8px 16px', cursor: 'pointer' }}>
            {paused ? '▶ Continue' : '∥ Pause'}
          </button>
          <button onClick={() => setIdx(0)} style={{ background: 'transparent', border: `1px solid ${theme.gold}`, color: theme.ink, ...smallCaps(theme, 10), padding: '8px 16px', cursor: 'pointer' }}>
            ↺ From the top
          </button>
          {idx >= LORDS_PRAYER.length - 1 && collect && collect.text && (
            <button onClick={() => setPhase('collect')} style={{ background: theme.ink, border: 'none', color: theme.paper, ...smallCaps(theme, 10), padding: '8px 16px', cursor: 'pointer' }}>
              Continue → This Week's Prayer
            </button>
          )}
        </div>
        <div style={{ marginTop: 22, ...smallCaps(theme, 9), color: theme.inkMute }}>
          {idx + 1} / {LORDS_PRAYER.length}
        </div>
      </div>
    </ModalShell>
  );
}

// ─────────────────────────────────────────────────────────────
// PrayerChain — handoff between visitors
// ─────────────────────────────────────────────────────────────
function PrayerChainCard({ theme }) {
  const carried = React.useMemo(() => {
    const stored = localStorage.getItem('mumc_carried');
    if (stored) return JSON.parse(stored);
    const prayers = [
      { from: 'Eleanor', for: 'her grandson’s safe return' },
      { from: 'Thomas',  for: 'patience with his neighbor' },
      { from: 'Sarah',   for: 'a clear answer' },
      { from: 'James',   for: 'the family of a dear friend' },
    ];
    const pick = prayers[Math.floor(Math.random() * prayers.length)];
    localStorage.setItem('mumc_carried', JSON.stringify(pick));
    return pick;
  }, []);
  const [released, setReleased] = React.useState(false);

  return (
    <div style={{ padding: '14px 22px 0' }}>
      <div style={{
        background: `linear-gradient(180deg, ${theme.paper}, ${theme.bgAlt})`,
        padding: 18, border: `1px solid ${theme.gold}55`, position: 'relative',
      }}>
        <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 8 }}>† The Prayer Chain</div>
        {!released ? (
          <>
            <div style={{ fontFamily: theme.fonts.display, fontSize: 18, color: theme.ink, lineHeight: 1.3, fontWeight: 400 }}>
              You are carrying <span style={{ fontStyle: 'italic', color: theme.accent }}>{carried.from}</span>’s prayer
            </div>
            <div style={{ fontFamily: theme.fonts.display, fontSize: 17, color: theme.inkSoft, fontStyle: 'italic', marginTop: 4 }}>
              for {carried.for}.
            </div>
            <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkMute, lineHeight: 1.5, marginTop: 10 }}>
              Hold them in mind for a moment. When you’re ready, release the prayer and pass it onward — the next visitor will carry one of yours.
            </div>
            <button onClick={() => { softChime(660); setReleased(true); localStorage.removeItem('mumc_carried'); }} style={{ marginTop: 14, padding: '10px 16px', background: theme.gold, color: theme.paper, border: 'none', ...smallCaps(theme, 10), cursor: 'pointer' }}>
              ☆ Release & pass on
            </button>
          </>
        ) : (
          <div style={{ fontFamily: theme.fonts.display, fontStyle: 'italic', fontSize: 17, color: theme.ink, padding: '10px 0', textAlign: 'center' }}>
            Thank you. The prayer continues. <span style={{ ...smallCaps(theme, 9), color: theme.gold, fontStyle: 'normal' }}>Amen.</span>
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Sit With Someone button — pairs visitors with a host
// ─────────────────────────────────────────────────────────────
function SitWithSomeone({ theme }) {
  const [stage, setStage] = React.useState('idle'); // idle | pairing | matched
  const hosts = [
    { name: 'Becky Dunnaway', sub: 'Retired business manager', tag: 'left aisle, back' },
    { name: 'Michele Parker', sub: 'Church secretary · 20 years at MUMC', tag: 'left aisle, back' },
    { name: 'Pastor Jonathan', sub: 'Will meet you at the door', tag: 'narthex' },
  ];
  const [match, setMatch] = React.useState(null);

  const start = () => {
    setStage('pairing');
    setTimeout(() => {
      setMatch(hosts[Math.floor(Math.random() * hosts.length)]);
      setStage('matched');
      softChime(880);
    }, 1600);
  };

  return (
    <div style={{ padding: '14px 22px 0' }}>
      <div style={{ background: theme.paper, padding: 18, border: `1px solid ${theme.rule}` }}>
        <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 6 }}>Coming this Sunday?</div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 22, color: theme.ink, fontWeight: 500, lineHeight: 1.15 }}>
          Sit with someone <span style={{ fontStyle: 'italic' }}>familiar</span>.
        </div>
        <div style={{ fontFamily: theme.fonts.body, fontSize: 13, color: theme.inkSoft, lineHeight: 1.45, marginTop: 6 }}>
          We’ll pair you with a member who has volunteered to host newcomers. They’ll save you a seat and answer your questions.
        </div>

        {stage === 'idle' && (
          <button onClick={start} style={{ marginTop: 14, width: '100%', padding: '12px', background: theme.ink, color: theme.paper, border: 'none', ...smallCaps(theme, 10), cursor: 'pointer' }}>
            ✦ &nbsp; Pair me with a host
          </button>
        )}
        {stage === 'pairing' && (
          <div style={{ marginTop: 16, textAlign: 'center', padding: '14px 0', fontFamily: theme.fonts.display, fontStyle: 'italic', color: theme.inkSoft, fontSize: 15, animation: 'breathe 1.2s infinite' }}>
            Asking the saints…
          </div>
        )}
        {stage === 'matched' && match && (
          <div style={{ marginTop: 14, padding: 14, background: theme.bgAlt, border: `1px solid ${theme.gold}` }}>
            <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 4 }}>You're paired with</div>
            <div style={{ fontFamily: theme.fonts.display, fontSize: 22, fontWeight: 500, color: theme.ink, fontStyle: 'italic' }}>{match.name}</div>
            <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 2 }}>{match.sub}</div>
            <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginTop: 8 }}>↳ Find them in the {match.tag}</div>
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// YearWheel — circular liturgical year
// ─────────────────────────────────────────────────────────────
// Brief explanations of each arc on the Year Wheel. These render in a
// small modal when a visitor taps a season — quietly catechetical, not
// a treatise. Each has a one-line subtitle and a short paragraph.
const YEAR_WHEEL_INFO = {
  advent: {
    name: 'Advent',
    subtitle: 'Holy waiting · the four weeks before Christmas',
    color: 'Violet (or royal blue)',
    body: 'Advent — from the Latin adventus, "coming" — is the four-week season of expectation that begins the Christian year. It looks both backward to the prophets who longed for Messiah and forward to Christ\'s return at the end of all things. The mood is one of holy waiting, watchful prayer, and quiet preparation.',
  },
  christmas: {
    name: 'Christmas',
    subtitle: 'The Twelve Days of the Incarnation',
    color: 'White and gold',
    body: 'Christmas is the twelve-day feast of the Incarnation — God taking on human flesh in the child of Bethlehem. The season begins at sundown on Christmas Eve and runs through Epiphany Eve. The mood is one of joy, song, and wonder, the kind that settles into the bones of a household.',
  },
  epiphany: {
    name: 'Epiphany',
    subtitle: 'The light of Christ revealed to the world',
    color: 'Green (Sundays after Epiphany), white for the feasts',
    body: 'Epiphany — Greek for "manifestation" — celebrates the revelation of Christ to the world. The season opens with the visit of the Magi, includes Jesus\' baptism in the Jordan and the Transfiguration on the mountain, and runs until Lent. The mood is light dawning over a darkened earth.',
  },
  lent: {
    name: 'Lent',
    subtitle: 'Forty days of repentance and the Cross',
    color: 'Violet (or sackcloth)',
    body: 'Lent — from Old English lencten, "lengthening of days" — is the forty-day journey of repentance, prayer, and self-examination leading to Easter. Modeled on Christ\'s forty days in the wilderness, the season calls Christians to prayer, fasting, and almsgiving. The Sundays of Lent are not counted in the forty days; they remain little Easters in the midst of the fast.',
  },
  easter: {
    name: 'Easter',
    subtitle: 'The Great Fifty Days of resurrection joy',
    color: 'White and gold',
    body: 'Easter is not a single day but a Great Fifty Days — the most ancient and central feast of the Christian year. From the Vigil of Easter through Ascension Day to Pentecost, the Church celebrates Christ\'s victory over death. The mood is alleluia, recovered after the long silence of Lent.',
  },
  pentecost_day: {
    name: 'Pentecost',
    subtitle: 'The day the Spirit fell · birthday of the Church',
    color: 'Red',
    body: 'Pentecost — from Greek pentēkostḗ, "fiftieth" — is the great feast of the Holy Spirit, when the Spirit descended on the apostles in tongues of fire and the Church was born from the upper room. It is sometimes called "the birthday of the Church." After this day the long green season of growth begins.',
  },
  pentecost: {
    name: 'Season after Pentecost',
    subtitle: 'The long green season of growth',
    color: 'Green',
    body: 'The long season after Pentecost is the longest stretch of the Christian year — also known as Ordinary Time, though there is nothing ordinary about it. It is the season of growth: of living the resurrection life, of teaching, of building up the body of Christ, of doing the patient work of the gospel between the high feasts of redemption and the eschaton.',
  },
  reign: {
    name: 'The Reign of Christ',
    subtitle: 'Christ the King · the last Sunday before Advent',
    color: 'White and gold',
    body: 'The Reign of Christ — also called Christ the King Sunday — is the final Sunday of the Christian year. Established in 1925 and adopted across Western Christianity, it proclaims the sovereignty of Christ over all peoples and powers, and points forward to the consummation of all things in him. The next Sunday begins Advent, and the year turns again.',
  },
};

function YearWheelSeasonModal({ theme, info, onClose }) {
  return (
    <ModalShell theme={theme} onClose={onClose} title={info.name} subtitle={info.subtitle}>
      <div style={{ padding: '24px 28px 60px', background: theme.paper, animation: 'fadein 0.4s ease' }}>
        <div style={{
          fontFamily: theme.fonts.display, fontSize: 17, lineHeight: 1.7,
          color: theme.ink, fontStyle: 'italic',
        }}>
          {info.body}
        </div>
        <div style={{
          marginTop: 26, padding: '14px 16px',
          background: theme.bgAlt, borderLeft: `3px solid ${theme.gold}`,
        }}>
          <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 6 }}>Liturgical color</div>
          <div style={{ fontFamily: theme.fonts.body, fontSize: 14, color: theme.ink }}>{info.color}</div>
        </div>
      </div>
    </ModalShell>
  );
}

function YearWheel({ theme, size = 280 }) {
  const r = size / 2;
  const cx = r, cy = r;
  const [selected, setSelected] = React.useState(null);

  // Compute actual liturgical season boundaries for the current year.
  // Falls back to a static approximation if the helper isn't loaded.
  let seasons = null;
  let todayPct = 0;
  let labelYears = '';
  try {
    if (typeof getLiturgicalSeasons === 'function') {
      seasons = getLiturgicalSeasons();
      todayPct = seasons.today;
      labelYears = `${seasons.startYear} — ${seasons.endYear}`;
    }
  } catch (e) { /* fall through */ }

  if (!seasons) {
    // Approximate fallback when the helper isn't loaded
    seasons = {
      advent:    { from: 0,    to: 0.07 },
      christmas: { from: 0.07, to: 0.10 },
      epiphany:  { from: 0.10, to: 0.22 },
      lent:      { from: 0.22, to: 0.35 },
      easter:    { from: 0.35, to: 0.50 },
      pentecost: { from: 0.50, to: 0.97 },
      reign:     { from: 0.97, to: 1 },
    };
    labelYears = String(new Date().getFullYear());
  }

  // Arc order goes clockwise from the top: Advent at 12 o'clock,
  // running through the year and ending with Christ the King.
  // Pentecost Day gets a small red wedge between Easter and the long
  // green Season-after-Pentecost arc — same visual width as Reign.
  // Each arc carries an `info` key for the modal lookup. The thin red
  // Pentecost-Day wedge has no label but is clickable.
  const arcs = [
    { ...seasons.advent,       name: 'Advent',    col: '#3a2858', info: 'advent' },
    { ...seasons.christmas,    name: 'Christmas', col: '#b8893d', info: 'christmas' },
    { ...seasons.epiphany,     name: 'Epiphany',  col: '#4a7042', info: 'epiphany' },
    { ...seasons.lent,         name: 'Lent',      col: '#4a3858', info: 'lent' },
    { ...seasons.easter,       name: 'Easter',    col: '#a87838', info: 'easter' },
    seasons.pentecostDay && { ...seasons.pentecostDay, name: '', col: '#9a2828', info: 'pentecost_day' },
    { ...seasons.pentecost,    name: 'Pentecost', col: '#3a5a3a', info: 'pentecost' },
    { ...seasons.reign,        name: 'Reign',     col: '#c8983d', info: 'reign' },
  ].filter(Boolean);

  const polar = (pct, radius) => {
    const a = pct * Math.PI * 2 - Math.PI / 2;
    return [cx + Math.cos(a) * radius, cy + Math.sin(a) * radius];
  };
  const arcPath = (from, to, ri, ro) => {
    const [x1, y1] = polar(from, ri);
    const [x2, y2] = polar(to, ri);
    const [x3, y3] = polar(to, ro);
    const [x4, y4] = polar(from, ro);
    const large = (to - from) > 0.5 ? 1 : 0;
    return `M${x1} ${y1} A${ri} ${ri} 0 ${large} 1 ${x2} ${y2} L${x3} ${y3} A${ro} ${ro} 0 ${large} 0 ${x4} ${y4} Z`;
  };

  const ri = r - 56, ro = r - 14;
  const [tx, ty] = polar(todayPct, (ri + ro) / 2);

  // Render labels for nearly all arcs; very thin ones get a smaller font.
  const minLabelArc = 0.012;
  const tightLabelArc = 0.04;

  return (
    <>
    <div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
        {/* outer & inner rims */}
        <circle cx={cx} cy={cy} r={ro} fill="none" stroke={theme.gold} strokeWidth="0.7" opacity="0.5"/>
        <circle cx={cx} cy={cy} r={ri} fill="none" stroke={theme.gold} strokeWidth="0.7" opacity="0.5"/>
        {/* arcs — clickable; each opens a small explanation modal */}
        {arcs.map((a, i) => (
          <g key={i} style={{ cursor: a.info ? 'pointer' : 'default' }}
             onClick={() => a.info && setSelected(a.info)}>
            <path d={arcPath(a.from, a.to, ri, ro)} fill={a.col} opacity="0.85">
              {a.info && <title>{(YEAR_WHEEL_INFO[a.info] && YEAR_WHEEL_INFO[a.info].name) || a.name}</title>}
            </path>
            <path d={arcPath(a.from, a.to, ri, ro)} fill="none" stroke={theme.paper} strokeWidth="1" pointerEvents="none"/>
          </g>
        ))}
        {/* today indicator — pointer plus dot */}
        <line x1={cx} y1={cy} x2={tx} y2={ty} stroke={theme.ink} strokeWidth="1.2" opacity="0.5" pointerEvents="none"/>
        <circle cx={tx} cy={ty} r="7" fill={theme.paper} stroke={theme.ink} strokeWidth="1.5" pointerEvents="none"/>
        <circle cx={tx} cy={ty} r="2.5" fill={theme.ink} pointerEvents="none"/>
        {/* center label */}
        <text x={cx} y={cy - 8} textAnchor="middle" pointerEvents="none" style={{ fontFamily: theme.fonts.display, fontSize: 13, fontStyle: 'italic', fill: theme.inkSoft }}>The Church's Year</text>
        <text x={cx} y={cy + 14} textAnchor="middle" pointerEvents="none" style={{ fontFamily: theme.fonts.display, fontSize: 18, fontWeight: 500, fill: theme.accent, letterSpacing: '0.02em' }}>{labelYears}</text>
        {/* arc labels — small caps. Tight arcs (Christmas, Reign) get a smaller font.
            Pentecost is broken into PENTE-/COST across two lines so it always fits
            cleanly on the green arc regardless of viewport size. */}
        {arcs.map((a, i) => {
          const span = a.to - a.from;
          if (span < minLabelArc) return null;
          if (!a.name) return null; // intentionally untitled (e.g., Pentecost Day wedge)
          const tight = span < tightLabelArc;
          const mid = (a.from + a.to) / 2;
          const [lx, ly] = polar(mid, (ri + ro) / 2);
          const isPentecost = a.name === 'Pentecost';
          // Hardcode a warm cream for the labels so they read well on the
          // saturated arc colors regardless of light or dark mode (theme.paper
          // becomes near-black in dark mode, which would vanish on dark arcs).
          const labelStyle = { fontFamily: theme.fonts.mono, fontSize: tight ? 6.5 : 8, letterSpacing: tight ? '0.08em' : '0.15em', fill: '#fdf6e3', fontWeight: 500 };
          if (isPentecost) {
            // Two-line label with hyphen so it always fits in the green band.
            return (
              <text key={i} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle"
                    pointerEvents="none" style={labelStyle}>
                <tspan x={lx} dy="-0.42em">PENTE-</tspan>
                <tspan x={lx} dy="1.05em">COST</tspan>
              </text>
            );
          }
          return (
            <text key={i} x={lx} y={ly} textAnchor="middle" dominantBaseline="middle"
                  pointerEvents="none" style={labelStyle}>
              {a.name.toUpperCase()}
            </text>
          );
        })}
      </svg>
    </div>

    {/* Tap hint — shown subtly below the wheel so visitors discover the affordance */}
    <div style={{
      textAlign: 'center',
      ...smallCaps(theme, 8),
      color: theme.inkMute,
      marginTop: -4,
      marginBottom: 4,
      letterSpacing: '0.18em',
    }}>
      ↳ &nbsp; Tap a season to learn more
    </div>

    {selected && YEAR_WHEEL_INFO[selected] && (
      <YearWheelSeasonModal
        theme={theme}
        info={YEAR_WHEEL_INFO[selected]}
        onClose={() => setSelected(null)}
      />
    )}
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// ModalShell — full-screen modal with paper texture and close
// ─────────────────────────────────────────────────────────────
function ModalShell({ theme, onClose, title, subtitle, children }) {
  return (
    <div style={{
      position: 'fixed', inset: 0, zIndex: 200,
      background: theme.bg, animation: 'fadein 0.25s',
      display: 'flex', flexDirection: 'column',
    }} className="paper-grain">
      <div style={{ padding: '54px 22px 16px', background: theme.paper, borderBottom: `1px solid ${theme.rule}`, flexShrink: 0 }}>
        <button onClick={() => onClose()} style={{ background: 'transparent', border: 'none', cursor: 'pointer', ...smallCaps(theme, 9), color: theme.inkMute, padding: 0, marginBottom: 10 }}>‹ &nbsp; Close</button>
        {subtitle && <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 6 }}>{subtitle}</div>}
        <h1 style={{ fontFamily: theme.fonts.display, fontWeight: 500, fontSize: 32, lineHeight: 1, margin: 0, color: theme.ink, fontStyle: 'italic' }}>{title}</h1>
      </div>
      <div style={{ flex: 1, overflow: 'auto' }}>
        {children}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// NotificationOptIn — request permission and subscribe to push.
//
// Flow:
//   1. Detect feature support (Notification, ServiceWorker, PushManager).
//      If any are missing — fail quietly, render nothing. We don't want to
//      tease users on browsers that don't support web push (looking at you,
//      iOS Safari before 16.4 / non-PWA installs).
//   2. On mount, fetch /api/config to learn the VAPID public key. If push
//      isn't configured server-side yet, render nothing.
//   3. Show a small card. If already subscribed, show "You're subscribed"
//      and an unsubscribe link. Otherwise, show "Get reminders".
//   4. Click → request permission → subscribe via SW → POST to /api/subscribe.
// ─────────────────────────────────────────────────────────────
function NotificationOptIn({ theme }) {
  const [supported, setSupported] = React.useState(false);
  const [vapidKey, setVapidKey]   = React.useState(null);
  const [permission, setPermission] = React.useState(
    typeof Notification !== 'undefined' ? Notification.permission : 'default'
  );
  const [subscribed, setSubscribed] = React.useState(false);
  const [busy, setBusy]   = React.useState(false);
  const [error, setError] = React.useState('');

  // Detect support + load VAPID key + check existing subscription
  React.useEffect(() => {
    let cancelled = false;
    const ok = typeof window !== 'undefined'
      && 'serviceWorker' in navigator
      && 'PushManager'    in window
      && 'Notification'   in window;
    if (!ok) return;

    (async () => {
      try {
        const res = await fetch('/api/config');
        if (!res.ok) return;
        const cfg = await res.json();
        if (cancelled) return;
        if (!cfg.vapidPublicKey) return;
        setVapidKey(cfg.vapidPublicKey);
        setSupported(true);

        const reg = await navigator.serviceWorker.ready;
        const existing = await reg.pushManager.getSubscription();
        if (!cancelled && existing) setSubscribed(true);
      } catch (_) { /* render nothing */ }
    })();

    return () => { cancelled = true; };
  }, []);

  if (!supported || !vapidKey) return null;

  const urlBase64ToUint8Array = (b64) => {
    const padding = '='.repeat((4 - b64.length % 4) % 4);
    const base64 = (b64 + padding).replace(/-/g, '+').replace(/_/g, '/');
    const raw = atob(base64);
    const out = new Uint8Array(raw.length);
    for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
    return out;
  };

  const subscribe = async () => {
    setBusy(true); setError('');
    try {
      const perm = await Notification.requestPermission();
      setPermission(perm);
      if (perm !== 'granted') {
        setError(perm === 'denied'
          ? 'Notifications are blocked. You can enable them in your browser settings.'
          : 'Notifications were not enabled.');
        setBusy(false);
        return;
      }
      const reg = await navigator.serviceWorker.ready;
      let sub = await reg.pushManager.getSubscription();
      if (!sub) {
        sub = await reg.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: urlBase64ToUint8Array(vapidKey),
        });
      }
      const res = await fetch('/api/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(sub.toJSON()),
      });
      if (!res.ok) throw new Error(`Subscribe failed (${res.status})`);
      setSubscribed(true);
      softChime(880);
    } catch (e) {
      setError(String(e.message || e));
    } finally {
      setBusy(false);
    }
  };

  const unsubscribe = async () => {
    setBusy(true); setError('');
    try {
      const reg = await navigator.serviceWorker.ready;
      const sub = await reg.pushManager.getSubscription();
      if (sub) {
        await fetch('/api/subscribe', {
          method: 'DELETE',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ endpoint: sub.endpoint }),
        }).catch(() => {});
        await sub.unsubscribe();
      }
      setSubscribed(false);
    } catch (e) {
      setError(String(e.message || e));
    } finally {
      setBusy(false);
    }
  };

  return (
    <>
      <div style={{ padding: '24px 22px 0' }}>
        <MissalRule theme={theme} label="Stay Close" />
      </div>
    <div style={{ padding: '14px 22px 0' }}>
      <div style={{ background: theme.paper, padding: 18, border: `1px solid ${theme.rule}` }}>
        <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 6 }}>† Stay close</div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 22, color: theme.ink, fontWeight: 500, lineHeight: 1.2 }}>
          A gentle <span style={{ fontStyle: 'italic' }}>note</span> on Sundays.
        </div>
        <div style={{ fontFamily: theme.fonts.body, fontSize: 13, color: theme.inkSoft, lineHeight: 1.5, marginTop: 6 }}>
          Allow notifications and we’ll send a quiet reminder before service, and the occasional word of welcome from the church on the corner. No spam, ever — only what matters.
        </div>

        {!subscribed && (
          <button
            onClick={subscribe}
            disabled={busy}
            style={{
              marginTop: 14, width: '100%', padding: 12,
              background: theme.ink, color: theme.paper, border: 'none',
              ...smallCaps(theme, 10),
              cursor: busy ? 'wait' : 'pointer', opacity: busy ? 0.7 : 1,
            }}
          >
            {busy ? 'One moment…' : '✦  Get Sunday reminders'}
          </button>
        )}

        {subscribed && (
          <div style={{ marginTop: 14 }}>
            <div style={{ padding: 10, background: theme.bgAlt, border: `1px solid ${theme.gold}55`,
              fontFamily: theme.fonts.display, fontStyle: 'italic', fontSize: 15, color: theme.ink, textAlign: 'center' }}>
              You’re subscribed. Peace be with you.
            </div>
            <button
              onClick={unsubscribe}
              disabled={busy}
              style={{
                marginTop: 8, width: '100%', padding: '8px 12px',
                background: 'transparent', color: theme.inkMute,
                border: `1px solid ${theme.rule}`,
                ...smallCaps(theme, 9), cursor: busy ? 'wait' : 'pointer',
              }}
            >
              {busy ? '…' : 'Turn off reminders'}
            </button>
          </div>
        )}

        {error && (
          <div style={{ marginTop: 10, fontFamily: theme.fonts.body, fontSize: 12, color: theme.accent }}>
            {error}
          </div>
        )}
      </div>
    </div>
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// HymnOfTheWeek — small card on the home page; opens HymnModal
// when tapped. Hymn rotates weekly by liturgical season.
// ─────────────────────────────────────────────────────────────
function HymnOfTheWeekCard({ theme, onOpen }) {
  const hymn = (typeof getHymnOfWeek === 'function') ? getHymnOfWeek() : null;
  if (!hymn) return null;

  // First line of first stanza, used as a teaser
  const firstLine = (hymn.stanzas && hymn.stanzas[0] || '').split('\n')[0];

  return (
    <div style={{ padding: '14px 22px 0' }}>
      <button onClick={onOpen} style={{
        width: '100%', textAlign: 'left', cursor: 'pointer',
        background: theme.paper, border: `1px solid ${theme.gold}55`,
        padding: 18, position: 'relative',
        fontFamily: 'inherit',
      }}>
        <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 6 }}>
          ♪ Hymn of the Week
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 22, color: theme.ink, fontWeight: 500, lineHeight: 1.15, fontStyle: 'italic' }}>
          {hymn.title}
        </div>
        <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 4 }}>
          {hymn.author}{hymn.hymnal ? ` · ${hymn.hymnal}` : ''}
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 14, color: theme.inkSoft, fontStyle: 'italic', marginTop: 10, lineHeight: 1.4 }}>
          "{firstLine}"
        </div>
        <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginTop: 12 }}>
          ↳ Read & listen
        </div>
      </button>
    </div>
  );
}

// HymnModal — full text of the week's hymn, with audio link or embedded
// player. The audio field can be a hymnary.org URL (opens in new tab) or
// a local /assets/hymns/*.mp3 path (renders as an <audio> player inline).

// Sourced recording manifest — for hymns where the audio is a public-domain
// or Creative Commons recording downloaded from Wikimedia Commons or the
// Internet Archive (rather than recorded by our own organist). Entries here
// produce an honest "Public-domain organ recording" label and a small
// attribution link. When our organist eventually replaces a file with their
// own recording, simply remove its entry from this map and the modal will
// automatically switch to "Played by our organist." Full attribution
// details live in /assets/hymns/ATTRIBUTIONS.md.
// Most files are licensed organ recordings from Hymns Without Words
// (Richard Irwin Music). Permission granted by HWW for use on this site —
// see ATTRIBUTIONS.md. Two hymns (what-wondrous-love, spirit-of-god-descend)
// remain on Wikimedia Commons sources because HWW doesn't have them in the
// right setting.
const HWW = (tune, descriptor) => ({
  label:   `Organ · ${tune}`,
  license: 'Hymns Without Words (used with permission)',
  url:     'https://play.hymnswithoutwords.com/',
  descriptor,
});

const HYMN_AUDIO_SOURCES = {
  'o-come-emmanuel':            HWW('VENI EMMANUEL'),
  'come-thou-long-expected':    HWW('CROSS OF JESUS'),
  'hark-the-herald':            HWW('MENDELSSOHN'),
  'joy-to-the-world':           HWW('ANTIOCH', 'with trumpet descant'),
  'we-three-kings':             HWW('KINGS OF ORIENT', 'chamber ensemble'),
  'as-with-gladness':           HWW('DIX'),
  'when-i-survey':              HWW('ROCKINGHAM'),
  'christ-the-lord-is-risen':   HWW('EASTER HYMN'),
  'thine-be-the-glory':         HWW('MACCABEUS', 'with trumpet'),
  'come-holy-ghost-our-hearts': HWW('VENI CREATOR SPIRITUS'),
  'holy-holy-holy':             HWW('NICAEA', 'with descant'),
  'amazing-grace':              HWW('NEW BRITAIN'),
  'be-thou-my-vision':          HWW('SLANE'),
  'a-mighty-fortress':          HWW('EIN FESTE BURG'),
  'love-divine':                HWW('BLAENWERN'),
  'and-can-it-be':              HWW('SAGINA'),
  'blessed-assurance':          HWW('ASSURANCE'),
  'it-is-well':                 HWW('VILLE DU HAVRE'),
  'for-all-the-saints':         HWW('SINE NOMINE', 'with trumpet descant'),
  'praise-to-the-lord':         HWW('LOBE DEN HERREN'),
  'o-god-our-help':             HWW('ST. ANNE', 'with descant'),
  // Smallchurchmusic.com / Hymnary.org — Clyde McLennan's collection,
  // non-commercial church use permitted:
  'o-for-a-thousand-tongues':   { label: 'Organ · AZMON', license: 'Clyde McLennan / Hymnary.org', url: 'https://hymnary.org/hymnal/scm' },
  // Organ accompaniment from the pastor's own collection:
  'were-you-there':             { label: 'Organ accompaniment · WERE YOU THERE', license: 'From the pastor\'s collection' },
  // The two we kept on Wikimedia (HWW didn't have the right tune):
  'what-wondrous-love':         { label: 'Recording by Howie Mitchell', license: 'CC BY 3.0 US', url: 'https://commons.wikimedia.org/wiki/File:Howie_Mitchell_-_08_-_What_Wondrous_Love_Is_This.ogg' },
  'spirit-of-god-descend':      { label: 'LibriVox · Army & Navy Hymnal (1921)', license: 'Public domain', url: 'https://commons.wikimedia.org/wiki/File:Army_and_Navy_Hymnal-0029.ogg' },
};

function HymnModal({ theme, onClose }) {
  const hymn = (typeof getHymnOfWeek === 'function') ? getHymnOfWeek() : null;
  React.useEffect(() => { try { playSound('page-turn'); } catch(e){} }, []);

  // Auto-discover a local organ recording. The convention: drop an MP3 at
  // /assets/hymns/{hymn.id}.mp3 (e.g., /assets/hymns/amazing-grace.mp3).
  // On modal mount, probe that URL with a HEAD request; if it returns a
  // valid audio Content-Type, render the inline <audio> player. If no
  // local file exists (404 or non-audio response), fall back to the
  // hymnary.org link the hymn data already carries.
  //
  // Result: music director records the church's organist over time, drops
  // the MP3 into the folder using the hymn ID as the filename, and the
  // inline player appears automatically on the next page load. Zero code
  // changes per recording added.
  const [localAudioUrl, setLocalAudioUrl] = React.useState(null);
  React.useEffect(() => {
    if (!hymn) return;
    let cancelled = false;
    const url = `/assets/hymns/${hymn.id}.mp3`;
    fetch(url, { method: 'HEAD', cache: 'no-store' })
      .then(r => {
        if (cancelled || !r.ok) return;
        const ctype = (r.headers.get('content-type') || '').toLowerCase();
        if (ctype.startsWith('audio/') || ctype === 'application/octet-stream') {
          setLocalAudioUrl(url);
        }
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, [hymn && hymn.id]);

  if (!hymn) return null;

  return (
    <ModalShell theme={theme} onClose={onClose} title={hymn.title} subtitle="Hymn of the Week">
      <div style={{ padding: '24px 28px 60px', background: theme.paper, animation: 'fadein 0.4s ease' }}>
        <div style={{ fontFamily: theme.fonts.body, fontSize: 13, color: theme.inkSoft, fontStyle: 'italic', marginBottom: 6 }}>
          {hymn.author}
        </div>
        {hymn.hymnal && (
          <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 18 }}>
            {hymn.hymnal}
          </div>
        )}

        {/* Audio: prefer local recording (probed in the effect above) over
            the remote hymnary.org link in the hymn data. The label and
            optional attribution depend on whether the file is from our
            own organist or a sourced public-domain / CC recording. */}
        {localAudioUrl && (() => {
          const src = HYMN_AUDIO_SOURCES[hymn.id];
          const isOurOrganist = !src;
          return (
            <div style={{ margin: '8px 0 22px' }}>
              <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginBottom: 6 }}>
                ♪ &nbsp; {isOurOrganist ? 'Played by our organist' : src.label}
              </div>
              <audio controls preload="none" src={localAudioUrl} style={{ width: '100%' }}>
                Your browser does not support audio.
              </audio>
              {!isOurOrganist && (
                <div style={{
                  marginTop: 6,
                  fontFamily: theme.fonts.body,
                  fontSize: 11,
                  fontStyle: 'italic',
                  color: theme.inkMute,
                  lineHeight: 1.4,
                }}>
                  {src.license}
                  {src.url && (
                    <>
                      {' '}·{' '}
                      <a href={src.url} target="_blank" rel="noopener noreferrer" style={{
                        color: theme.inkMute,
                        textDecoration: 'underline',
                        textDecorationStyle: 'dotted',
                      }}>source</a>
                    </>
                  )}
                </div>
              )}
            </div>
          );
        })()}
        {!localAudioUrl && hymn.audio && (
          <div style={{ margin: '8px 0 22px' }}>
            <a href={hymn.audio} target="_blank" rel="noopener noreferrer" style={{
              display: 'inline-block',
              padding: '8px 16px', border: `1px solid ${theme.gold}`,
              color: theme.ink, textDecoration: 'none',
              ...smallCaps(theme, 10),
            }}>♪ &nbsp;Listen on hymnary.org &nbsp;↗</a>
          </div>
        )}

        {/* Stanzas */}
        {hymn.stanzas.map((stanza, i) => (
          <div key={i} style={{
            marginBottom: 22,
            display: 'flex', gap: 14,
            fontFamily: theme.fonts.display,
            fontSize: 17, lineHeight: 1.55, color: theme.ink,
          }}>
            <div style={{
              ...smallCaps(theme, 10), color: theme.gold,
              flexShrink: 0, paddingTop: 4, minWidth: 18, textAlign: 'right',
            }}>{i + 1}</div>
            <div style={{ whiteSpace: 'pre-line' }}>{stanza}</div>
          </div>
        ))}

        {/* Refrain */}
        {hymn.refrain && (
          <div style={{
            marginTop: 8, padding: 16,
            background: theme.bgAlt, borderLeft: `3px solid ${theme.gold}`,
            fontFamily: theme.fonts.display,
            fontSize: 16, fontStyle: 'italic', lineHeight: 1.5, color: theme.ink,
            whiteSpace: 'pre-line',
          }}>
            <div style={{ ...smallCaps(theme, 9), color: theme.gold, fontStyle: 'normal', marginBottom: 6 }}>Refrain</div>
            {hymn.refrain}
          </div>
        )}

        <div style={{ marginTop: 30, textAlign: 'center', ...smallCaps(theme, 9), color: theme.inkMute }}>
          † &nbsp; Soli Deo Gloria &nbsp; †
        </div>
      </div>
    </ModalShell>
  );
}

// ─────────────────────────────────────────────────────────────
// ExamenModal — five-stage Ignatian examen, walked at a contemplative
// pace. Self-paced (Next) so people can sit with each step as long as
// they want, but each stage has a soft chime to mark the transition.
// ─────────────────────────────────────────────────────────────
const EXAMEN_STAGES = [
  {
    label: 'I · Gratitude',
    body: 'Pause. Recall the moments of today, large and small — the first sip of coffee, a face you saw, work that came together, an unexpected mercy. Let these gifts return to your awareness, and be grateful.',
    pause: 'Stay here as long as you wish.',
  },
  {
    label: 'II · Light',
    body: 'Ask the Holy Spirit to guide your reflection — to show you what you might miss looking on your own. Pray: Come, Holy Spirit, and help me to see this day with your eyes.',
    pause: '',
  },
  {
    label: 'III · Review',
    body: 'Walk slowly through the hours. Where did you receive love? Where did you give it? What stirred you? What troubled you? Where did you feel the presence — or the absence — of God?',
    pause: 'Let the day return to you, scene by scene.',
  },
  {
    label: 'IV · Repent',
    body: 'What was lacking today? Where did you fall short — through what you did, or what you left undone? Speak it honestly. Receive the mercy that is already given.',
    pause: 'There is grace here.',
  },
  {
    label: 'V · Resolve',
    body: 'Look toward tomorrow. What grace will you need? Ask for it now. Commit again to walking in love.',
    pause: '',
  },
  {
    label: 'Closing',
    body: 'The Lord be with you.\nAnd with thy spirit.\n\nLet us bless the Lord.\nThanks be to God.\n\nMay the souls of the faithful, through the mercy of God, rest in peace.',
    closing: true,
  },
];

function ExamenModal({ theme, onClose }) {
  const [stage, setStage] = React.useState(0);
  const current = EXAMEN_STAGES[stage];
  const isLast = stage === EXAMEN_STAGES.length - 1;
  // softChime() on stage change (below) already provides the audio cue —
  // no separate page-turn here, that would stack two sounds together.

  // Soft chime when transitioning between stages
  React.useEffect(() => {
    softChime(stage === 0 ? 880 : (stage % 2 === 0 ? 660 : 550));
  }, [stage]);

  const next = () => { if (!isLast) setStage(s => s + 1); };
  const back = () => { if (stage > 0) setStage(s => s - 1); };
  const restart = () => setStage(0);

  return (
    <ModalShell theme={theme} onClose={onClose} title="Examen" subtitle="The Daily Examination of Conscience">
      <div style={{
        padding: '40px 28px 60px', minHeight: 380,
        background: theme.paper,
        display: 'flex', flexDirection: 'column',
        animation: 'fadein 0.6s ease',
      }}>
        <div style={{ ...smallCaps(theme, 10), color: theme.gold, textAlign: 'center', marginBottom: 24 }}>
          {current.label}
        </div>

        <div style={{
          flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
          maxWidth: 520, margin: '0 auto',
        }}>
          <div style={{
            fontFamily: theme.fonts.display,
            fontSize: current.closing ? 18 : 20,
            fontStyle: current.closing ? 'normal' : 'italic',
            lineHeight: 1.55, color: theme.ink, textAlign: 'center',
            whiteSpace: 'pre-line',
          }}>
            {current.body}
            {current.pause && (
              <div style={{
                ...smallCaps(theme, 9), fontStyle: 'normal', color: theme.inkMute,
                marginTop: 24, animation: 'breathe 3s infinite',
              }}>
                {current.pause}
              </div>
            )}
          </div>
        </div>

        <div style={{
          marginTop: 32, display: 'flex', gap: 12,
          justifyContent: 'center', flexWrap: 'wrap',
        }}>
          {stage > 0 && !isLast && (
            <button onClick={back} style={{
              background: 'transparent', border: `1px solid ${theme.rule}`,
              color: theme.inkMute, ...smallCaps(theme, 10),
              padding: '8px 14px', cursor: 'pointer',
            }}>‹ &nbsp; Back</button>
          )}
          {!isLast && (
            <button onClick={next} style={{
              background: theme.ink, border: 'none', color: theme.paper,
              ...smallCaps(theme, 10), padding: '10px 20px', cursor: 'pointer',
            }}>
              Continue &nbsp; ›
            </button>
          )}
          {isLast && (
            <>
              <button onClick={restart} style={{
                background: 'transparent', border: `1px solid ${theme.gold}`,
                color: theme.ink, ...smallCaps(theme, 10),
                padding: '8px 16px', cursor: 'pointer',
              }}>↺ &nbsp; From the top</button>
              <button onClick={onClose} style={{
                background: theme.ink, border: 'none', color: theme.paper,
                ...smallCaps(theme, 10), padding: '10px 18px', cursor: 'pointer',
              }}>
                ✓ &nbsp; Amen
              </button>
            </>
          )}
        </div>

        {/* Progress dots */}
        <div style={{
          marginTop: 22, display: 'flex', gap: 6,
          justifyContent: 'center',
        }}>
          {EXAMEN_STAGES.map((_, i) => (
            <div key={i} style={{
              width: i === stage ? 22 : 6, height: 6,
              background: i <= stage ? theme.gold : theme.rule,
              borderRadius: 3, transition: 'all 0.4s ease',
            }} />
          ))}
        </div>
      </div>
    </ModalShell>
  );
}

// ExamenCard — invitation card for the examen, dignified and quiet.
// Renders only in the evening (after 6 PM local), when the practice is
// traditionally observed. During the day it stays out of sight.
function ExamenCard({ theme, onOpen, alwaysShow = false }) {
  const [show, setShow] = React.useState(alwaysShow);
  React.useEffect(() => {
    if (alwaysShow) return;
    const h = new Date().getHours();
    setShow(h >= 18 || h < 4); // 6 PM through 4 AM
  }, [alwaysShow]);

  if (!show) return null;

  return (
    <>
      <div style={{ padding: '24px 22px 0' }}>
        <MissalRule theme={theme} label="Compline" />
      </div>
    <div style={{ padding: '14px 22px 0' }}>
      <button onClick={onOpen} style={{
        width: '100%', textAlign: 'left', cursor: 'pointer',
        background: `linear-gradient(180deg, ${theme.paper}, ${theme.bgAlt})`,
        padding: 18, border: `1px solid ${theme.rule}`,
        fontFamily: 'inherit',
      }}>
        <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 6 }}>
          † Before sleep
        </div>
        <div style={{ fontFamily: theme.fonts.display, fontSize: 22, color: theme.ink, fontWeight: 500, lineHeight: 1.15 }}>
          The <span style={{ fontStyle: 'italic' }}>Examen</span> — five minutes
        </div>
        <div style={{ fontFamily: theme.fonts.body, fontSize: 13, color: theme.inkSoft, lineHeight: 1.5, marginTop: 6 }}>
          A quiet walk through the day. Gratitude, light, review, mercy, resolve. Sit with the Spirit before you rest.
        </div>
        <div style={{ ...smallCaps(theme, 9), color: theme.gold, marginTop: 12 }}>
          ↳ Begin
        </div>
      </button>
    </div>
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// IOSInstallPrompt — first-visit hint for iPhone Safari users that
// they can pin MUMC to their home screen. Only renders when we
// detect iOS Safari running OUTSIDE the installed PWA. Dismissed
// state is persisted to localStorage so we don't nag.
//
// Why this exists: iOS Safari has no native "install" UI. A first-
// time visitor would never discover the PWA exists unless told.
// Without an install, web push notifications can't reach the
// device — Apple gates them behind the home-screen install.
// ─────────────────────────────────────────────────────────────
function IOSInstallPrompt({ theme }) {
  const [show, setShow] = React.useState(false);

  React.useEffect(() => {
    if (typeof window === 'undefined' || typeof navigator === 'undefined') return;

    // iOS detection (covers iPhone, iPad masquerading as desktop on iPadOS,
    // and iPod). The `MSStream` check rules out IE11 mobile (extinct but cheap).
    const ua = navigator.userAgent || '';
    const isIOS = (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1))
      && !window.MSStream;
    if (!isIOS) return;

    // Already installed as PWA — nothing to prompt.
    const isStandalone = window.navigator.standalone === true
      || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
    if (isStandalone) return;

    try {
      if (localStorage.getItem('mumc.iosInstallDismissed') === '1') return;
    } catch (e) {}

    // Don't pop instantly — let the page render and the visitor see
    // they've arrived somewhere first. 4 seconds feels right.
    const t = setTimeout(() => setShow(true), 4000);
    return () => clearTimeout(t);
  }, []);

  const dismiss = () => {
    try { localStorage.setItem('mumc.iosInstallDismissed', '1'); } catch (e) {}
    setShow(false);
  };

  if (!show) return null;

  return (
    <div style={{
      position: 'fixed', left: 12, right: 12, bottom: 'calc(70px + env(safe-area-inset-bottom))',
      zIndex: 150, maxWidth: 480, marginLeft: 'auto', marginRight: 'auto',
      background: theme.paper, border: `1px solid ${theme.gold}`,
      padding: 16, animation: 'fadein 0.5s ease',
      boxShadow: '0 8px 32px rgba(60,40,20,0.22)',
    }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
        <div style={{ color: theme.gold, fontSize: 22, lineHeight: 1, flexShrink: 0, marginTop: 2 }}>†</div>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ ...smallCaps(theme, 9), color: theme.accent, marginBottom: 6 }}>
            For the full experience
          </div>
          <div style={{ fontFamily: theme.fonts.display, fontSize: 16, lineHeight: 1.5, color: theme.ink }}>
            Tap the share button{' '}
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={theme.accent}
                 strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
                 style={{ display: 'inline-block', verticalAlign: '-2px', margin: '0 2px' }}>
              <path d="M12 16V4M12 4L8 8M12 4l4 4" />
              <path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" />
            </svg>{' '}
            below, then{' '}
            <span style={{ fontStyle: 'italic', color: theme.accent }}>Add to Home Screen</span>{' '}
            — pin MUMC for quick access and Sunday reminders.
          </div>
          <button onClick={dismiss} style={{
            marginTop: 12, padding: '8px 16px', border: 'none',
            background: theme.ink, color: theme.paper,
            ...smallCaps(theme, 9), cursor: 'pointer',
          }}>Got it</button>
        </div>
        <button onClick={dismiss} aria-label="Dismiss"
          style={{
            background: 'transparent', border: 'none', cursor: 'pointer',
            color: theme.inkMute, padding: 4, fontSize: 18, lineHeight: 1,
            fontFamily: theme.fonts.mono, flexShrink: 0, marginLeft: -4,
          }}>×</button>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// AccessibilityButton + AccessibilityModal — user-facing controls
// for text size, contrast, and reduced motion. Settings persist
// to localStorage and are applied globally via body classes and
// inline zoom on first paint.
//
// The audience for MUMC skews older — this is meaningful, not
// theatre. Small but visible "AA" button anchors above the tab
// bar so it's reachable from any screen.
// ─────────────────────────────────────────────────────────────
const A11Y_DEFAULTS = { textScale: 'normal', highContrast: false, reduceMotion: false, soundEnabled: false };
const A11Y_KEY = 'mumc.a11y';

function readA11y() {
  try {
    const raw = localStorage.getItem(A11Y_KEY);
    if (!raw) return { ...A11Y_DEFAULTS };
    return { ...A11Y_DEFAULTS, ...JSON.parse(raw) };
  } catch (e) { return { ...A11Y_DEFAULTS }; }
}

function writeA11y(s) {
  try { localStorage.setItem(A11Y_KEY, JSON.stringify(s)); } catch (e) {}
}

// Apply settings to document body. Idempotent — safe to call repeatedly.
function applyA11y(s) {
  if (typeof document === 'undefined') return;
  // Text scale via CSS zoom — supported in all modern browsers including
  // Firefox 126+ (May 2024). Avoids refactoring the codebase's px-based
  // inline styles to rem.
  const scale = s.textScale === 'large' ? 1.15 : s.textScale === 'xlarge' ? 1.3 : 1;
  document.body.style.zoom = String(scale);

  // High-contrast and reduced-motion ride on body classes so deeply nested
  // components can opt in via CSS without prop drilling.
  document.body.classList.toggle('mumc-high-contrast', !!s.highContrast);
  document.body.classList.toggle('mumc-reduce-motion', !!s.reduceMotion);
}

// Inject the global CSS that the accessibility toggles depend on.
// Runs once on first import; idempotent.
(function injectA11yStyles() {
  if (typeof document === 'undefined') return;
  if (document.getElementById('mumc-a11y-styles')) return;
  const style = document.createElement('style');
  style.id = 'mumc-a11y-styles';
  style.textContent = `
    body.mumc-high-contrast {
      filter: contrast(1.18) saturate(0.92);
    }
    body.mumc-high-contrast a,
    body.mumc-high-contrast button {
      text-decoration-thickness: 2px;
    }
    body.mumc-reduce-motion *,
    body.mumc-reduce-motion *::before,
    body.mumc-reduce-motion *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
      scroll-behavior: auto !important;
    }
  `;
  document.head.appendChild(style);
})();

function AccessibilityButton({ theme }) {
  const [open, setOpen] = React.useState(false);
  const [settings, setSettings] = React.useState(readA11y);

  // Re-apply on mount (in case page just loaded)
  React.useEffect(() => { applyA11y(settings); }, [settings]);

  const update = (patch) => {
    const next = { ...settings, ...patch };
    setSettings(next);
    writeA11y(next);
    applyA11y(next);
  };

  return (
    <>
      <button
        onClick={() => setOpen(true)}
        aria-label="Accessibility settings"
        title="Text size & contrast"
        style={{
          position: 'fixed',
          right: 14, bottom: 'calc(64px + env(safe-area-inset-bottom))',
          zIndex: 90,
          width: 40, height: 40, borderRadius: '50%',
          background: theme.paper, color: theme.ink,
          border: `1px solid ${theme.gold}`,
          boxShadow: '0 4px 14px rgba(60,40,20,0.18)',
          cursor: 'pointer',
          fontFamily: theme.fonts.body,
          fontSize: 14, fontWeight: 600,
          letterSpacing: '0.05em',
        }}
      >AA</button>

      {open && (
        <ModalShell theme={theme} onClose={() => setOpen(false)}
          title="Accessibility" subtitle="Text & contrast">
          <div style={{ padding: '24px 28px 60px', background: theme.paper }}>

            {/* Text size */}
            <div style={{ marginBottom: 26 }}>
              <div style={{ ...smallCaps(theme, 10), color: theme.accent, marginBottom: 10 }}>
                Text size
              </div>
              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                {[
                  { v: 'normal', label: 'Normal', size: 14 },
                  { v: 'large', label: 'Large', size: 17 },
                  { v: 'xlarge', label: 'Extra large', size: 20 },
                ].map(opt => {
                  const active = settings.textScale === opt.v;
                  return (
                    <button key={opt.v} onClick={() => update({ textScale: opt.v })} style={{
                      flex: 1, minWidth: 90,
                      padding: '14px 10px',
                      background: active ? theme.ink : 'transparent',
                      color: active ? theme.paper : theme.ink,
                      border: `1px solid ${active ? theme.ink : theme.rule}`,
                      cursor: 'pointer',
                      fontFamily: theme.fonts.display,
                      fontSize: opt.size,
                      fontWeight: 500,
                      transition: 'all 0.15s',
                    }}>
                      {opt.label}
                    </button>
                  );
                })}
              </div>
              <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 8, lineHeight: 1.5, fontStyle: 'italic' }}>
                Scales every page. The change applies immediately.
              </div>
            </div>

            {/* Higher contrast */}
            <div style={{ marginBottom: 26 }}>
              <div style={{ ...smallCaps(theme, 10), color: theme.accent, marginBottom: 10 }}>
                Higher contrast
              </div>
              <button onClick={() => update({ highContrast: !settings.highContrast })} style={{
                width: '100%', padding: 14, textAlign: 'left',
                background: settings.highContrast ? theme.ink : 'transparent',
                color: settings.highContrast ? theme.paper : theme.ink,
                border: `1px solid ${settings.highContrast ? theme.ink : theme.rule}`,
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
                fontFamily: theme.fonts.body,
              }}>
                <span style={{ fontFamily: theme.fonts.display, fontSize: 16 }}>
                  {settings.highContrast ? 'On — sharper edges' : 'Off — natural'}
                </span>
                <span style={{ ...smallCaps(theme, 9), opacity: 0.7 }}>
                  {settings.highContrast ? '✓' : '○'}
                </span>
              </button>
              <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 8, lineHeight: 1.5, fontStyle: 'italic' }}>
                Increases contrast across the page for easier reading.
              </div>
            </div>

            {/* Reduce motion */}
            <div style={{ marginBottom: 26 }}>
              <div style={{ ...smallCaps(theme, 10), color: theme.accent, marginBottom: 10 }}>
                Reduce motion
              </div>
              <button onClick={() => update({ reduceMotion: !settings.reduceMotion })} style={{
                width: '100%', padding: 14, textAlign: 'left',
                background: settings.reduceMotion ? theme.ink : 'transparent',
                color: settings.reduceMotion ? theme.paper : theme.ink,
                border: `1px solid ${settings.reduceMotion ? theme.ink : theme.rule}`,
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
                fontFamily: theme.fonts.body,
              }}>
                <span style={{ fontFamily: theme.fonts.display, fontSize: 16 }}>
                  {settings.reduceMotion ? 'On — still & calm' : 'Off — gentle motion'}
                </span>
                <span style={{ ...smallCaps(theme, 9), opacity: 0.7 }}>
                  {settings.reduceMotion ? '✓' : '○'}
                </span>
              </button>
              <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 8, lineHeight: 1.5, fontStyle: 'italic' }}>
                Disables animations and gentle transitions across the site.
              </div>
            </div>

            {/* Sounds — soft ambient audio for navigation, candle-lighting,
                confirmations. Off by default. Tapping ON also primes the
                AudioContext with a quiet test chime so the user knows it
                works (iOS requires a user gesture before audio plays). */}
            <div style={{ marginBottom: 12 }}>
              <div style={{ ...smallCaps(theme, 10), color: theme.accent, marginBottom: 10 }}>
                Sounds
              </div>
              <button onClick={() => {
                const next = !settings.soundEnabled;
                update({ soundEnabled: next });
                if (next) {
                  // Defer slightly so the persisted flag is in localStorage
                  // before playSound reads it.
                  setTimeout(() => { try { window.playSound && window.playSound('chime'); } catch(e){} }, 100);
                }
              }} style={{
                width: '100%', padding: 14, textAlign: 'left',
                background: settings.soundEnabled ? theme.ink : 'transparent',
                color: settings.soundEnabled ? theme.paper : theme.ink,
                border: `1px solid ${settings.soundEnabled ? theme.ink : theme.rule}`,
                cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
                fontFamily: theme.fonts.body,
              }}>
                <span style={{ fontFamily: theme.fonts.display, fontSize: 16 }}>
                  {settings.soundEnabled ? 'On — bells & pages' : 'Off — silent'}
                </span>
                <span style={{ ...smallCaps(theme, 9), opacity: 0.7 }}>
                  {settings.soundEnabled ? '✓' : '○'}
                </span>
              </button>
              <div style={{ fontFamily: theme.fonts.body, fontSize: 12, color: theme.inkSoft, marginTop: 8, lineHeight: 1.5, fontStyle: 'italic' }}>
                Soft ambient sounds — page turns, candle-lighting, the bell — at low volume in the background.
              </div>
            </div>

            <div style={{ marginTop: 22, padding: 14, background: theme.bgAlt, borderLeft: `3px solid ${theme.gold}` }}>
              <div style={{ fontFamily: theme.fonts.display, fontSize: 14, fontStyle: 'italic', color: theme.inkSoft, lineHeight: 1.5 }}>
                Your preferences are saved on this device and remembered the next time you visit.
              </div>
            </div>
          </div>
        </ModalShell>
      )}
    </>
  );
}

// Apply persisted accessibility settings on first paint, before any
// component mounts. Avoids a flash of normal-size content before the
// modal is opened. Imported into app shells.
(function applyA11yOnLoad() {
  if (typeof window === 'undefined') return;
  try { applyA11y(readA11y()); } catch (e) {}
})();

// ─────────────────────────────────────────────────────────────
// Background ambient sounds — played at low volume with a brief
// fade-in so they feel like incense in the room rather than UI
// notifications. Off by default; visitors enable from the AA panel.
//
// Sound files are sourced from Mixkit (royalty-free, commercial use OK,
// no attribution required). See assets/sounds/.
//
// Web Audio API (rather than HTMLAudioElement) so we get proper gain
// envelopes for the fade-in and reliable replay-without-rewind on
// rapid successive plays.
// ─────────────────────────────────────────────────────────────
const SOUND_FILES = {
  'page-turn':    '/assets/sounds/page-turn.mp3',
  'chime':        '/assets/sounds/chime.mp3',
  'match-strike': '/assets/sounds/match-strike.mp3',
  'church-bell':  '/assets/sounds/church-bell.mp3',
};
// Volumes deliberately very low — these are ambient atmosphere, not UI
// notifications. Match-strike is the lone exception: a candle being lit
// is the deliberate gesture, so the strike rises to feel grounded.
const SOUND_VOLUME = {
  'page-turn':    0.05,
  'chime':        0.15,
  'match-strike': 0.50,
  'church-bell':  0.15,
};

let _audioCtx = null;
const _bufferCache = {};
// Track the last meaningful sound (chime / match-strike / church-bell) so
// the ambient page-turn yields to it. Page-turn is the wallpaper sound;
// any deliberate sound takes precedence and silences the page-turn within
// the cooldown window.
let _lastMeaningfulSoundAt = 0;
const PAGE_TURN_YIELD_MS = 700;

function ensureAudioCtx() {
  if (typeof window === 'undefined') return null;
  if (_audioCtx) return _audioCtx;
  const Ctx = window.AudioContext || window.webkitAudioContext;
  if (!Ctx) return null;
  try { _audioCtx = new Ctx(); } catch (e) { return null; }
  return _audioCtx;
}

async function loadSoundBuffer(name) {
  if (_bufferCache[name]) return _bufferCache[name];
  const url = SOUND_FILES[name];
  if (!url) return null;
  const ctx = ensureAudioCtx();
  if (!ctx) return null;
  try {
    const r = await fetch(url);
    if (!r.ok) return null;
    const arr = await r.arrayBuffer();
    const buf = await ctx.decodeAudioData(arr);
    _bufferCache[name] = buf;
    return buf;
  } catch (e) { return null; }
}

// Public API. Plays the named sound at its preset volume with a brief
// fade-in. Silent if the user has soundEnabled=false, the browser
// blocks AudioContext, the page is hidden, or the buffer can't load.
//
// Yielding rule: page-turn is the ambient wallpaper sound. If a
// "meaningful" sound (chime, match-strike, church-bell) has played
// in the last PAGE_TURN_YIELD_MS, suppress the page-turn so the two
// don't stack. Any other call always plays.
async function playSound(name) {
  if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
  let enabled = false;
  try {
    const s = JSON.parse(localStorage.getItem(A11Y_KEY) || '{}');
    enabled = !!s.soundEnabled;
  } catch (e) {}
  if (!enabled) return;

  // Page-turn yields to any meaningful sound that fired recently.
  if (name === 'page-turn' && Date.now() - _lastMeaningfulSoundAt < PAGE_TURN_YIELD_MS) {
    return;
  }

  const ctx = ensureAudioCtx();
  if (!ctx) return;
  if (ctx.state === 'suspended') {
    try { await ctx.resume(); } catch (e) { return; }
  }

  const buffer = await loadSoundBuffer(name);
  if (!buffer) return;

  try {
    const source = ctx.createBufferSource();
    source.buffer = buffer;
    const gain = ctx.createGain();
    const target = SOUND_VOLUME[name] || 0.30;
    // Fade in over 60ms so onsets don't feel clicky.
    const t0 = ctx.currentTime;
    gain.gain.setValueAtTime(0, t0);
    gain.gain.linearRampToValueAtTime(target, t0 + 0.060);
    source.connect(gain).connect(ctx.destination);
    source.start(0);

    // Mark non-page-turn sounds as "meaningful" so the next page-turn
    // request within the cooldown window stays quiet.
    if (name !== 'page-turn') {
      _lastMeaningfulSoundAt = Date.now();
    }
  } catch (e) { /* swallow */ }
}

// Make playSound globally accessible so screens can call it without
// an explicit import.
if (typeof window !== 'undefined') {
  window.playSound = playSound;
}

// ─────────────────────────────────────────────────────────────
// LiveNowBanner — Sunday-morning "we're worshipping right now"
// ─────────────────────────────────────────────────────────────
// Slim banner pinned to the top of the viewport. Visible only during the
// LIVESTREAM_WINDOW (Sunday 10:00–11:45 Central by default). Re-checks the
// window every 30 seconds so it appears/disappears on its own without a
// page reload. Tap → jump to home, where HomeScreen's hero auto-rotates to
// the YouTube embed during the same window.
//
// On the home page during the window, the entire hero is already the
// livestream — so this banner mostly serves visitors browsing other pages
// (Prayer Chapel, About, etc.) on Sunday morning, who'd otherwise have no
// idea worship was happening.
function LiveNowBanner({ theme, onGoHome }) {
  const [live, setLive] = React.useState(() =>
    typeof isLivestreamHour === 'function' ? isLivestreamHour() : false);

  React.useEffect(() => {
    const id = setInterval(() => {
      try { setLive(isLivestreamHour()); } catch (e) { /* ignore */ }
    }, 30000);
    return () => clearInterval(id);
  }, []);

  // While the banner is showing, push the rest of the page down by its
  // height so content doesn't slide under the fixed-position bar. Cleared
  // on unmount or when the live window ends.
  React.useEffect(() => {
    if (!live) {
      document.documentElement.style.removeProperty('--live-banner-pad');
      return;
    }
    document.documentElement.style.setProperty('--live-banner-pad',
      'calc(36px + env(safe-area-inset-top))');
    return () => {
      document.documentElement.style.removeProperty('--live-banner-pad');
    };
  }, [live]);

  if (!live) return null;

  return (
    <button onClick={onGoHome} aria-label="Go to live worship" style={{
      position: 'fixed', top: 0, left: 0, right: 0, zIndex: 90,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      gap: 12, padding: '8px 16px',
      background: '#7a2e2e',
      color: '#fdf6e3',
      border: 'none', cursor: 'pointer',
      fontFamily: theme.fonts.mono,
      fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
      boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
      paddingTop: 'calc(8px + env(safe-area-inset-top))',
    }}>
      <span aria-hidden="true" style={{
        width: 9, height: 9, borderRadius: '50%',
        background: '#ff4a4a',
        boxShadow: '0 0 0 0 rgba(255,74,74,0.7)',
        animation: 'liveNowPulse 1.6s ease-out infinite',
        flexShrink: 0,
      }}/>
      <span style={{ whiteSpace: 'nowrap' }}>
        Worshipping right now — join us
      </span>
      <span aria-hidden="true" style={{
        fontFamily: theme.fonts.display, fontSize: 16, lineHeight: 1,
        opacity: 0.85, flexShrink: 0,
      }}>›</span>
      <style>{`
        @keyframes liveNowPulse {
          0%   { box-shadow: 0 0 0 0 rgba(255, 74, 74, 0.65); }
          70%  { box-shadow: 0 0 0 10px rgba(255, 74, 74, 0); }
          100% { box-shadow: 0 0 0 0 rgba(255, 74, 74, 0); }
        }
      `}</style>
    </button>
  );
}

Object.assign(window, {
  SunlitGlass, DailyOfficeCard, OfficeModal, LectionaryModal, SaintRibbon,
  PrayWithUsModal, PrayerChainCard, SitWithSomeone, YearWheel, ModalShell,
  PdfViewerModal, NotificationOptIn,
  HymnOfTheWeekCard, HymnModal, ExamenModal, ExamenCard,
  IOSInstallPrompt, AccessibilityButton, LiveNowBanner,
});
