{% comment %}
  Daily Word  v3.5
  Lava Application: daily-word   Page block: Lava Application Content

  v3.5  Play now starts where you are: arriving via a passage's Read pill
        remembers that block, and the dock's play begins at its first
        chapter instead of the day's first (which also scroll-yanked the
        reader back to the top). Opening the whole day still starts at
        chapter one of the queue.
  v3.4  repairVerseIds: unattributed opening spans become verse 1 (the
        verse-2 highlight mystery, found by the DW timing log)
  v3.3  type=button everywhere (Rock WebForms postback reload)
  v3.x/v2.x  see prior headers

  Enabled Lava Commands on the BLOCK: none.
{% endcomment %}

{% if CurrentPerson %}

<script type="application/json" id="dw-plan-data">
{{ CurrentPerson | Attribute:'DailyWordPlan','RawValue' | Default:'null' }}
</script>

{% raw %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300..700;1,6..72,300..700&family=Work+Sans:wght@300;400;450;500;600;700&display=swap">
<style>
/* Daily Word - styles ported from "Reading Plan Builder v5 polish" and the
   Good News Church token sheets. Every rule is scoped under #dw-root so the
   site theme cannot reach in. Token values are verbatim from tokens/*.css. */

#dw-root {
  --forest: #165B48; --sage: #5F9B8C; --navy: #091B34;
  --coral: #E6633F; --taupe: #B0958D; --cream: #EEDDD5;
  --forest-700: #0E3D30; --forest-600: #124A3A; --forest-500: #165B48;
  --forest-400: #2E725E; --forest-300: #5F9B8C; --forest-200: #A6C6BC;
  --forest-100: #D8E7E2; --forest-50: #EEF5F2;
  --navy-900: #050F1F; --navy-700: #091B34; --navy-500: #1B3151;
  --navy-300: #4A5E7C; --navy-200: #8C9AB0; --navy-100: #C9D1DC;
  --coral-600: #C84E2C; --coral-500: #E6633F; --coral-400: #EE8163;
  --cream-50: #FBF6F2; --cream-100: #F5EBE4; --cream-200: #EEDDD5;
  --taupe-300: #C9B4AD; --taupe-400: #B0958D; --taupe-500: #94786F;
  --white: #FFFFFF; --gray-50: #F7F8F8; --gray-100: #EDEFF0;
  --gray-200: #DCE0E2; --gray-400: #9AA3A8; --gray-600: #5C666C;
  --text-strong: var(--navy-700); --text-body: #243650;
  --text-muted: var(--navy-300); --text-faint: var(--navy-200);
  --text-on-accent: #FFFFFF; --text-link: var(--forest-500);
  --surface-page: var(--cream-50); --surface-card: var(--white);
  --border-subtle: #E7E0D9; --border-default: #D8CFC7;
  --accent-primary: var(--forest-500); --accent-soft: var(--sage);
  --accent-energy: var(--coral-500);
  --font-serif: "Newsreader", "Iowan Old Style", Georgia, serif;
  --font-sans: "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-display: var(--font-serif); --font-body-f: var(--font-sans);
  --font-ui: var(--font-sans);
  --weight-light: 300; --weight-regular: 400; --weight-book: 450;
  --weight-medium: 500; --weight-semibold: 600; --weight-bold: 700;
  --track-display: -0.022em; --track-body: -0.006em; --track-eyebrow: 0.16em;
  --text-h3: 22px; --text-body-lg: 19px; --text-body-sm: 14px;
  --text-caption: 13px; --text-eyebrow: 12px;
  --ease: cubic-bezier(.23,1,.32,1);

  font-family: var(--font-body-f); color: var(--text-body);
  letter-spacing: var(--track-body); background: var(--surface-page);
  display: block; min-height: 100vh;
}

#dw-root h1, #dw-root p, #dw-root div, #dw-root span, #dw-root nav,
#dw-root header, #dw-root main, #dw-root section, #dw-root button, #dw-root select {
  margin: 0; padding: 0; border: 0; background: none; box-shadow: none;
  font-family: inherit; font-size: inherit; font-weight: inherit;
  line-height: inherit; color: inherit; text-align: left; text-transform: none;
  letter-spacing: inherit; float: none; width: auto; min-width: 0;
}
#dw-root *, #dw-root *::before, #dw-root *::after { box-sizing: border-box; }
#dw-root button, #dw-root select {
  -webkit-appearance: none; appearance: none; border-radius: 0;
  text-decoration: none; cursor: pointer; font-family: var(--font-ui);
  text-align: center;
}
/* icon buttons centre their glyph optically, not just by text-align */
#dw-root .dw-ghost, #dw-root .dw-round, #dw-root .dw-x, #dw-root .dw-fs,
#dw-root .dw-play, #dw-root .dw-qglyph {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0; text-align: center;
}
#dw-root select { -webkit-appearance: menulist; appearance: menulist; }
#dw-root h1 { font-weight: var(--weight-regular); }

@keyframes dw-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

#dw-root .dw-head { position: sticky; top: 0; z-index: 10;
  background: rgba(251,246,242,.88); backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border-subtle); }
#dw-root .dw-head-in { max-width: 1140px; margin: 0 auto; padding: 16px 24px;
  display: flex; align-items: center; justify-content: space-between; gap: 20px; }
#dw-root .dw-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
#dw-root .dw-wordmark { font-family: var(--font-display); font-size: 22px;
  letter-spacing: -0.028em; color: var(--text-strong); }
#dw-root .dw-rule { width: 18px; height: 1px; background: var(--accent-soft); flex: none; }
#dw-root .dw-tag { font-family: var(--font-ui); font-weight: 300;
  font-size: var(--text-caption); color: var(--text-muted); }
#dw-root .dw-nav { display: flex; gap: 24px; flex: none; }
#dw-root .dw-tab { font-family: var(--font-ui); font-size: var(--text-body-sm);
  color: var(--text-muted); border-bottom: 2px solid transparent; padding: 4px 0;
  transition: color 160ms var(--ease), border-color 160ms var(--ease); }
#dw-root .dw-tab.is-on { color: var(--forest-500); font-weight: var(--weight-semibold);
  border-bottom-color: var(--forest-500); }
#dw-root .dw-main { padding: 56px 24px 40px; }
#dw-root .dw-wrap { max-width: 720px; margin: 0 auto; }
#dw-root .dw-wrap-narrow { max-width: 660px; margin: 0 auto; }

#dw-root .dw-eyebrow { font-family: var(--font-ui); font-size: var(--text-eyebrow);
  font-weight: var(--weight-semibold); letter-spacing: var(--track-eyebrow);
  text-transform: uppercase; }
#dw-root .dw-sage { color: var(--accent-soft); margin-bottom: 18px; }
#dw-root .dw-taupe { color: var(--taupe-500); margin-bottom: 14px; }
#dw-root .dw-h1 { font-family: var(--font-display); font-weight: var(--weight-regular);
  font-size: clamp(40px, 8vw, 62px); line-height: 1.04; letter-spacing: -0.028em;
  color: var(--text-strong); margin: 0 0 20px; }
#dw-root .dw-h1 i, #dw-root .dw-h1 em { font-style: italic; }
#dw-root .dw-hero { margin-bottom: 48px; }
#dw-root .dw-lede { font-size: var(--text-body-lg); font-weight: 300; line-height: 1.65;
  color: var(--text-muted); margin: 0; max-width: 46ch; text-wrap: pretty; }
#dw-root .dw-cap { font-size: var(--text-caption); color: var(--text-faint); }

#dw-root .dw-card { display: flex; flex-direction: column; gap: 1px;
  background: var(--border-subtle); border: 1px solid var(--border-subtle);
  border-radius: 18px; overflow: hidden; box-shadow: 0 2px 6px rgba(9,27,52,.05); }
#dw-root .dw-sections { margin-bottom: 36px; }
#dw-root .dw-row { background: var(--surface-card); padding: 20px 22px; display: flex;
  align-items: center; gap: 18px; flex-wrap: wrap;
  transition: background-color 180ms var(--ease); }
#dw-root .dw-row:hover { background: var(--gray-50); }
#dw-root .dw-row-main { flex: 1; min-width: 190px; }
#dw-root .dw-secname { font-family: var(--font-display); font-size: 21px;
  letter-spacing: var(--track-display); color: var(--text-strong); }
#dw-root .dw-row-main .dw-cap { margin-top: 4px; }

#dw-root .dw-step { display: flex; align-items: center; gap: 4px; flex: none; }
#dw-root .dw-ghost { width: 44px; height: 44px; border-radius: 999px;
  color: var(--text-muted); font-size: 17px; line-height: 1;
  transition: transform 140ms var(--ease), background-color 140ms ease, color 140ms ease; }
#dw-root .dw-ghost:hover { background: var(--gray-100); color: var(--text-strong); }
#dw-root .dw-ghost:active { transform: scale(.97); }
#dw-root .dw-freq { min-width: 124px; text-align: center; }
#dw-root .dw-freq-n { font-family: var(--font-ui); font-weight: var(--weight-bold);
  font-size: 15px; letter-spacing: -0.006em; color: var(--forest-500); }
#dw-root .dw-freq .dw-cap { margin-top: 2px; }
#dw-root .dw-x { color: var(--gray-400); font-size: 19px; padding: 6px 4px; flex: none;
  transition: transform 140ms var(--ease), color 140ms ease; }
#dw-root .dw-x:hover { color: var(--coral-500); }
#dw-root .dw-x:active { transform: scale(.97); }

#dw-root .dw-twoup { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 36px; }
#dw-root .dw-select { flex: 1 1 300px; font-family: var(--font-ui);
  font-size: var(--text-body-sm); font-weight: var(--weight-book);
  padding: 13px 16px; min-height: 48px; border: 1px solid var(--border-default);
  border-radius: 10px; background: var(--surface-card); color: var(--text-strong);
  max-width: 100%; }
#dw-root .dw-chips { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 120px; }
#dw-root .dw-chip { border: 1px solid var(--forest-200); background: var(--surface-card);
  color: var(--forest-500); border-radius: 999px; padding: 10px 18px; min-height: 40px;
  font-family: var(--font-ui); font-size: var(--text-body-sm); font-weight: var(--weight-book);
  transition: transform 140ms var(--ease), background-color 160ms var(--ease); }
#dw-root .dw-chip:hover { background: var(--forest-50); }
#dw-root .dw-chip:active { transform: scale(.97); }
#dw-root .dw-chip.is-used { background: none; border-color: var(--border-subtle);
  color: var(--text-faint); }

#dw-root .dw-actionbar { position: sticky; bottom: 18px; display: flex;
  align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap;
  background: rgba(251,246,242,.9); backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px); border: 1px solid var(--border-subtle);
  border-radius: 16px; padding: 14px 16px; box-shadow: 0 6px 22px rgba(9,27,52,.08); }
#dw-root .dw-summary { font-size: var(--text-body-sm); color: var(--text-muted); }
#dw-root .dw-primary { background: var(--forest-500); color: var(--text-on-accent);
  border-radius: 10px; padding: 13px 26px; min-height: 48px;
  font-family: var(--font-ui); font-weight: var(--weight-semibold); font-size: 15px;
  transition: transform 140ms var(--ease), background-color 160ms var(--ease); }
#dw-root .dw-primary:hover { background: var(--forest-600); }
#dw-root .dw-primary:active { transform: scale(.97); }

#dw-root .dw-masthead { display: flex; align-items: flex-end;
  justify-content: space-between; gap: 20px; padding-bottom: 20px;
  border-bottom: 1px solid var(--border-default); margin-bottom: 8px; }
#dw-root .dw-mast-l { min-width: 0; }
#dw-root .dw-mast-l .dw-eyebrow { margin-bottom: 10px; }
#dw-root .dw-date { font-family: var(--font-display); font-weight: var(--weight-regular);
  font-size: clamp(36px, 8vw, 58px); line-height: 1; letter-spacing: -0.028em;
  color: var(--text-strong); margin: 0; }
#dw-root .dw-daynav { display: flex; gap: 6px; flex-shrink: 0; }
#dw-root .dw-round { width: 44px; height: 44px; border-radius: 999px;
  border: 1px solid var(--border-default); color: var(--text-strong); font-size: 15px;
  transition: transform 140ms var(--ease), background-color 140ms ease; }
#dw-root .dw-round:hover { background: var(--surface-card); }
#dw-root .dw-round:active { transform: scale(.97); }
#dw-root .dw-subrow { display: flex; align-items: center; justify-content: space-between;
  gap: 16px; margin-bottom: 30px; flex-wrap: wrap; }

#dw-root .dw-read { display: flex; align-items: center; gap: 18px; padding: 22px 24px;
  background: var(--surface-card); cursor: pointer;
  transition: filter 140ms var(--ease), background-color 180ms var(--ease);
  animation: dw-rise 260ms var(--ease) both; }
#dw-root .dw-read:hover { filter: brightness(.985); }
#dw-root .dw-read.is-done { background: var(--gray-50); }
#dw-root .dw-check { width: 24px; height: 24px; border-radius: 999px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; font-size: 14px;
  font-weight: 600; background: none; color: transparent;
  border: 1.5px solid var(--border-default);
  transition: background-color 140ms var(--ease), border-color 140ms var(--ease),
    color 140ms var(--ease); }
#dw-root .dw-check.is-on { background: var(--forest-500); color: #fff;
  border-color: var(--forest-500); }
#dw-root .dw-read-main { flex: 1; min-width: 0; }
#dw-root .dw-stream { display: block; font-size: var(--text-caption);
  font-weight: var(--weight-semibold); letter-spacing: var(--track-eyebrow);
  text-transform: uppercase; color: var(--taupe-500); margin-bottom: 6px; }
#dw-root .dw-passage { display: block; font-family: var(--font-display);
  font-size: clamp(21px, 4.4vw, 27px); letter-spacing: -0.026em; line-height: 1.15;
  color: var(--text-strong); transition: color 180ms var(--ease); }
#dw-root .dw-read.is-done .dw-passage { color: var(--text-faint);
  text-decoration: line-through; }
#dw-root .dw-read-main .dw-cap { margin-top: 6px; }
#dw-root .dw-rest { padding: 56px 0; color: var(--text-muted);
  font-family: var(--font-display); font-size: var(--text-h3); font-style: italic; }

#dw-root .dw-stats { display: grid; grid-template-columns: repeat(3, 1fr);
  margin-top: 36px; border-top: 1px solid var(--border-default);
  border-bottom: 1px solid var(--border-subtle); }
#dw-root .dw-stat { padding: 20px 4px 18px; }
#dw-root .dw-stat + .dw-stat { border-left: 1px solid var(--border-subtle);
  padding-left: 20px; }
#dw-root .dw-num { font-family: var(--font-display); font-size: 34px; line-height: 1;
  letter-spacing: -0.028em; color: var(--text-strong); }
#dw-root .dw-slab { font-family: var(--font-ui); font-size: var(--text-eyebrow);
  font-weight: var(--weight-semibold); letter-spacing: var(--track-eyebrow);
  text-transform: uppercase; color: var(--taupe-500); margin-top: 8px; }
#dw-root .dw-bar { height: 3px; background: var(--gray-100); border-radius: 999px;
  overflow: hidden; }
#dw-root .dw-bar span { display: block; height: 100%; border-radius: 999px;
  background: var(--forest-500); transition: width 280ms var(--ease); }

#dw-root .dw-catch { margin-top: 44px; }
#dw-root .dw-cu { display: flex; gap: 14px; align-items: center; padding: 14px 2px;
  border-bottom: 1px solid var(--border-subtle); cursor: pointer;
  transition: filter 140ms var(--ease); }
#dw-root .dw-cu:hover { filter: brightness(.985); }
#dw-root .dw-cu-ref { display: block; font-family: var(--font-display); font-size: 17px;
  letter-spacing: -0.02em; color: var(--text-strong); }
#dw-root .dw-cu .dw-check { width: 18px; height: 18px; }
#dw-root .dw-more { padding: 14px 2px; }

#dw-root .dw-signin { max-width: 560px; margin: 90px auto; padding: 0 24px; }

#dw-toast { position: fixed; left: 24px; bottom: 24px; z-index: 60;
  background: rgba(9,27,52,.92); color: #fff; font-family: "Work Sans", sans-serif;
  font-size: 13px; font-weight: 500; letter-spacing: -0.006em;
  padding: 10px 17px; border-radius: 999px; opacity: 0; transform: translateY(6px);
  pointer-events: none; box-shadow: 0 8px 24px rgba(9,27,52,.22);
  transition: opacity 200ms cubic-bezier(.23,1,.32,1),
    transform 200ms cubic-bezier(.23,1,.32,1); }
#dw-toast.is-on { opacity: 1; transform: translateY(0); }
#dw-toast.is-err { background: #E6633F; }

@media (max-width: 640px) {
  #dw-root .dw-main { padding: 34px 18px 34px; }
  #dw-root .dw-head-in { padding: 14px 18px; }
  #dw-root .dw-row, #dw-root .dw-read { padding: 18px 16px; }
  #dw-root .dw-step { width: 100%; justify-content: space-between; }
  #dw-root .dw-tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  #dw-root *, #dw-root *::before, #dw-root *::after {
    animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* ---------- day reader ---------- */
#dw-root .dw-wrap-read { max-width: 680px; margin: 0 auto; padding-bottom: 150px; }
#dw-root .dw-back { color: var(--text-muted); font-size: var(--text-body-sm);
  padding: 8px 12px 8px 0; min-height: 44px;
  transition: transform 140ms var(--ease), color 140ms ease; }
#dw-root .dw-back:hover { color: var(--text-strong); }
#dw-root .dw-back:active { transform: scale(.97); }
#dw-root .dw-read-head { display: flex; align-items: flex-start;
  justify-content: space-between; gap: 18px; margin-top: 6px; }
#dw-root .dw-read-title { font-family: var(--font-display);
  font-weight: var(--weight-regular); font-size: clamp(34px, 7vw, 54px);
  line-height: 1.03; letter-spacing: -0.028em; color: var(--text-strong);
  margin: 12px 0 0; }
#dw-root .dw-read-sub { margin-top: 10px; }
#dw-root .dw-fontctl { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
#dw-root .dw-fs { font-family: var(--font-serif); color: var(--text-faint);
  min-width: 40px; min-height: 40px;
  transition: transform 140ms var(--ease), color 140ms ease; }
#dw-root .dw-fs:hover { color: var(--text-strong); }
#dw-root .dw-fs:active { transform: scale(.97); }
#dw-root .dw-fs-sm { font-size: 14px; }
#dw-root .dw-fs-lg { font-size: 21px; }
#dw-root .dw-sagerule { width: 34px; height: 2px; background: var(--accent-soft);
  margin: 26px 0 8px; }
#dw-root .dw-loading { color: var(--text-muted); font-style: italic;
  font-family: var(--font-display); font-size: var(--text-h3); padding: 30px 0; }
#dw-root .dw-err { color: var(--coral-600); font-size: var(--text-body-sm); padding: 14px 0; }

#dw-root .dw-block { margin-top: 44px; animation: dw-rise 300ms var(--ease) both; }
#dw-root .dw-block-head { display: flex; align-items: flex-end;
  justify-content: space-between; gap: 16px; padding-bottom: 14px;
  border-bottom: 1px solid var(--border-subtle); margin-bottom: 24px; }
#dw-root .dw-block-l { min-width: 0; }
#dw-root .dw-block-ref { font-family: var(--font-display);
  font-size: clamp(22px, 4.6vw, 28px); letter-spacing: -0.026em; line-height: 1.1;
  color: var(--text-strong); }
#dw-root .dw-mark { flex-shrink: 0; border-radius: 999px; padding: 9px 16px;
  min-height: 40px; font-size: 13px; font-weight: var(--weight-book);
  border: 1px solid var(--border-default); color: var(--text-muted);
  transition: transform 140ms var(--ease), background-color 160ms var(--ease),
    border-color 160ms var(--ease), color 160ms var(--ease); }
#dw-root .dw-mark.is-on { border-color: var(--forest-500);
  background: var(--forest-500); color: #fff; }
#dw-root .dw-mark:active { transform: scale(.97); }

#dw-root .dw-passages { font-family: var(--font-display); letter-spacing: 0; }
#dw-root .dw-para { line-height: 1.8; color: var(--text-strong);
  margin: 0 0 0.35em; text-wrap: pretty; }
#dw-root .dw-para span { cursor: pointer; border-radius: 2px;
  transition: background-color 420ms var(--ease), box-shadow 420ms var(--ease); }
#dw-root .dw-para span.dw-verse-on { background: var(--forest-50);
  box-shadow: 0 0 0 4px var(--forest-50);
  transition: background-color 160ms var(--ease), box-shadow 160ms var(--ease); }
#dw-root .dw-note { font-size: var(--text-caption); color: var(--text-faint);
  margin-top: 6px; }
#dw-root .dw-note:first-of-type { margin-top: 34px; padding-top: 16px;
  border-top: 1px solid var(--border-subtle); }
#dw-root .dw-markday { margin-top: 30px; }
#dw-root .dw-readpill { flex-shrink: 0; font-size: var(--text-body-sm);
  font-weight: var(--weight-book); color: var(--forest-500);
  border: 1px solid var(--forest-200); border-radius: 999px; padding: 10px 18px;
  min-height: 44px; transition: transform 140ms var(--ease), background-color 140ms ease; }
#dw-root .dw-readpill:hover { background: var(--forest-50); }
#dw-root .dw-readpill:active { transform: scale(.97); }

/* ---------- glass audio dock (lives on body, outside #dw-root) ---------- */
#dw-dock-host { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
  display: flex; justify-content: center; pointer-events: none;
  padding: 0 16px calc(24px + env(safe-area-inset-bottom));
  font-family: "Work Sans", -apple-system, sans-serif; letter-spacing: -0.006em; }
#dw-dock-host .dw-dockwrap { pointer-events: none; max-width: 560px; width: 100%; }
#dw-dock-host .dw-dock { pointer-events: auto; display: flex; align-items: center;
  gap: 12px; padding: 8px 14px 8px 8px; border-radius: 999px;
  background: rgba(255,255,255,.46); backdrop-filter: blur(20px) saturate(150%);
  -webkit-backdrop-filter: blur(20px) saturate(150%);
  border: 1px solid rgba(255,255,255,.55); box-shadow: 0 12px 30px rgba(9,27,52,.2);
  color: #101C26; animation: dw-dock-in 260ms cubic-bezier(.23,1,.32,1) both; }
@keyframes dw-dock-in { from { transform: translateY(100%); } to { transform: none; } }
#dw-dock-host .dw-play { width: 52px; height: 52px; border-radius: 999px; flex: none;
  border: 0; background: #165B48; color: #fff; font-size: 16px; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: transform 140ms cubic-bezier(.23,1,.32,1); }
#dw-dock-host .dw-play:active { transform: scale(.94); }
#dw-dock-host .dw-dock-l { flex: 1; min-width: 0; }
#dw-dock-host .dw-dock-ref { font-size: 13px; font-weight: 600; color: #101C26;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#dw-dock-host .dw-dock-status { font-size: 11px; color: #33414D;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#dw-dock-host .dw-chips-row { display: flex; align-items: center; gap: 5px; flex: none; }
#dw-dock-host .dw-dchip { min-width: 38px; width: auto; height: 38px;
  border-radius: 999px; border: 0; padding: 0 11px; white-space: nowrap;
  background: rgba(255,255,255,.6); color: #101C26; font-size: 12px; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: inherit; flex: none;
  transition: transform 140ms cubic-bezier(.23,1,.32,1), background-color 160ms ease; }
#dw-dock-host .dw-dchip.dw-ico { width: 38px; padding: 0; }
#dw-dock-host .dw-dchip:active { transform: scale(.97); }
#dw-dock-host .dw-dchip.is-on { background: #091B34; color: #fff; }
#dw-dock-host .dw-queue { pointer-events: auto; margin: 0 auto 10px; max-width: 520px;
  max-height: 40vh; overflow-y: auto; border-radius: 22px; padding: 14px;
  background: rgba(255,255,255,.46); backdrop-filter: blur(20px) saturate(150%);
  -webkit-backdrop-filter: blur(20px) saturate(150%);
  border: 1px solid rgba(255,255,255,.55); box-shadow: 0 12px 30px rgba(9,27,52,.2);
  animation: dw-rise 220ms cubic-bezier(.23,1,.32,1) both; }
#dw-dock-host .dw-qhead { display: flex; justify-content: space-between; gap: 12px;
  font-size: 12px; font-weight: 600; color: #101C26; padding: 2px 8px 12px; }
#dw-dock-host .dw-qcount { font-weight: 400; color: #33414D; }
#dw-dock-host .dw-qrow { display: flex; align-items: center; gap: 12px; height: 46px;
  padding: 0 12px; border-radius: 14px; cursor: pointer;
  background: rgba(255,255,255,.26); margin-bottom: 4px; }
#dw-dock-host .dw-qrow.is-on { background: rgba(255,255,255,.7); }
#dw-dock-host .dw-qglyph { width: 20px; flex: none; font-size: 12px; color: #33414D; }
#dw-dock-host .dw-qrow.is-on .dw-qglyph { color: #091B34; }
#dw-dock-host .dw-qref { flex: 1; min-width: 0; font-family: "Newsreader", Georgia, serif;
  font-size: 17px; color: #101C26; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
#dw-dock-host .dw-qstream { font-size: 11px; color: #33414D; flex: none; }

@media (max-width: 520px) {
  #dw-dock-host { padding-left: 10px; padding-right: 10px;
    padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
  #dw-dock-host .dw-dock { gap: 8px; padding: 6px 10px 6px 6px; }
  #dw-dock-host .dw-play { width: 46px; height: 46px; }
  #dw-dock-host .dw-dock-status { display: none; }
  #dw-dock-host .dw-dock-ref { font-size: 12px; }
  #dw-dock-host .dw-chips-row { gap: 4px; }
  #dw-dock-host .dw-dchip { height: 34px; min-width: 34px; font-size: 11px;
    padding: 0 9px; }
  #dw-dock-host .dw-dchip.dw-ico { width: 34px; padding: 0; }
}
#dw-dock-host.dw-settled .dw-dock { animation: none; }
</style>

<div id="dw-root"></div>

<script>
(function () {
  "use strict";
  var DW_SAVE_URL = "/api/v2/lava-app/1/daily-word/save-plan";
  var DW_MIN_PER_CHAPTER = 4;
  var DW_BIBLE_KEY = "ihqFe6Ulyut2QBDL8qqXX";

class DWCore {
  constructor(props) {
    this.props = props || {};
    this.API = "https://rest.api.bible/v1";
    this.TEXT_BIBLE = "78a9f6124f344018-01"; // NIV 2011
    this.BOOKS = [
      ["Genesis",50],["Exodus",40],["Leviticus",27],["Numbers",36],["Deuteronomy",34],
      ["Joshua",24],["Judges",21],["Ruth",4],["1 Samuel",31],["2 Samuel",24],
      ["1 Kings",22],["2 Kings",25],["1 Chronicles",29],["2 Chronicles",36],["Ezra",10],
      ["Nehemiah",13],["Esther",10],["Job",42],["Psalms",150],["Proverbs",31],
      ["Ecclesiastes",12],["Song of Songs",8],["Isaiah",66],["Jeremiah",52],["Lamentations",5],
      ["Ezekiel",48],["Daniel",12],["Hosea",14],["Joel",3],["Amos",9],
      ["Obadiah",1],["Jonah",4],["Micah",7],["Nahum",3],["Habakkuk",3],
      ["Zephaniah",3],["Haggai",2],["Zechariah",14],["Malachi",4],
      ["Matthew",28],["Mark",16],["Luke",24],["John",21],["Acts",28],
      ["Romans",16],["1 Corinthians",16],["2 Corinthians",13],["Galatians",6],["Ephesians",6],
      ["Philippians",4],["Colossians",4],["1 Thessalonians",5],["2 Thessalonians",3],["1 Timothy",6],
      ["2 Timothy",4],["Titus",3],["Philemon",1],["Hebrews",13],["James",5],
      ["1 Peter",5],["2 Peter",3],["1 John",5],["2 John",1],["3 John",1],
      ["Jude",1],["Revelation",22]
    ];
    const names = this.BOOKS.map(b => b[0]);
    const range = (a, b) => names.slice(names.indexOf(a), names.indexOf(b) + 1);
    this.PRESETS = [
      { name: "Whole Bible", books: names },
      { name: "Old Testament", books: range("Genesis","Malachi") },
      { name: "New Testament", books: range("Matthew","Revelation") },
      { name: "Pentateuch", books: range("Genesis","Deuteronomy") },
      { name: "OT history", books: range("Joshua","Esther") },
      { name: "Wisdom & poetry", books: range("Job","Song of Songs") },
      { name: "Psalms", books: ["Psalms"] },
      { name: "Proverbs", books: ["Proverbs"] },
      { name: "Prophets", books: range("Isaiah","Malachi") },
      { name: "Major prophets", books: range("Isaiah","Daniel") },
      { name: "Minor prophets", books: range("Hosea","Malachi") },
      { name: "Gospels", books: range("Matthew","John") },
      { name: "Acts", books: ["Acts"] },
      { name: "Paul's letters", books: range("Romans","Philemon") },
      { name: "General letters", books: range("Hebrews","Jude") },
      { name: "Epistles", books: range("Romans","Jude") },
      { name: "Revelation", books: ["Revelation"] }
    ];
    this.FREQ_STEPS = [0.5, 1, 2, 3, 4, 6, 12, 24, 52];
    this.TEMPLATES = [
      { name: "Whole Bible in a year", desc: "Cover to cover — about 3¼ chapters a day.", sels: [
        { name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 1 }] },
      // M'Cheyne's 1842 calendar is four parallel bookmarks, not three streams
      // weighted by frequency. Each track is ~365-410 chapters read once, which
      // is why the original has a reading in every track every day. Modelling it
      // as "Psalms x2" instead left 65 days with no psalm. Book ORDER inside a
      // track is load-bearing here: dayChapters walks sel.books as given, so
      // placing Psalms mid-track is what makes the tracks open on Gen 1 / Mt 1 /
      // Ezra 1 / Acts 1 and close on 2 Chr 36 / Rev 22 / Mal 4 / Jn 21, exactly
      // as the printed calendar does.
      { name: "M'Cheyne", desc: "The classic 1842 Scottish calendar: four bookmarks — OT once, NT and Psalms twice — about 4 chapters a day.", sels: [
        { name: "Genesis onward", baseName: "M'Cheyne 1",
          books: range("Genesis","2 Chronicles"), freq: 1 },
        { name: "Matthew onward", baseName: "M'Cheyne 2",
          books: [...range("Matthew","John"), "Psalms", ...range("Acts","Revelation")], freq: 1 },
        { name: "Ezra onward", baseName: "M'Cheyne 3",
          books: range("Ezra","Malachi").filter(n => n !== "Psalms"), freq: 1 },
        { name: "Acts onward", baseName: "M'Cheyne 4",
          books: [...range("Acts","Revelation"), "Psalms", ...range("Matthew","John")], freq: 1 }] },
      { name: "New Testament in a year", desc: "A gentle pace — one chapter most days.", sels: [
        { name: "New Testament", baseName: "New Testament", books: range("Matthew","Revelation"), freq: 1 }] },
      { name: "Psalms & Proverbs monthly", desc: "The historic wisdom habit: five psalms and a proverb a day, twelve times a year.", sels: [
        { name: "Psalms", baseName: "Psalms", books: ["Psalms"], freq: 12 },
        { name: "Proverbs", baseName: "Proverbs", books: ["Proverbs"], freq: 12 }] },
      { name: "Gospels every season", desc: "Live in the life of Jesus — all four Gospels, four times a year.", sels: [
        { name: "Gospels", baseName: "Gospels", books: range("Matthew","John"), freq: 4 }] },
      { name: "Whole Bible in 90 days", desc: "Intensive: about 13 chapters a day, four passes in a year.", sels: [
        { name: "Whole Bible", baseName: "Whole Bible", books: names, freq: 4 }] }
    ];
    const saved = (props && props.saved) || null;
    this.state = saved || {
      view: "setup",
      selections: [
        { name: "Psalms", books: ["Psalms"], freq: 3 },
        { name: "Gospels", books: range("Matthew","John"), freq: 4 },
        { name: "Acts", books: ["Acts"], freq: 12 },
        { name: "Rest of OT", baseName: "Old Testament", books: range("Genesis","Malachi").filter(n => n !== "Psalms"), freq: 0.5 }
      ],
      startDate: null, checks: {}, viewDay: 0
    };
    if (this.state.follow === undefined) this.state.follow = true;
  }

  chaptersOf(books) { return books.reduce((s, n) => s + this.BOOKS.find(b => b[0] === n)[1], 0); }


  addSelection(name, books, freq) {
    const short = { "Old Testament": "OT", "New Testament": "NT" };
    const restName = (base) => "Rest of " + (short[base] || base);
    let newBooks = [...books];
    const sels = this.state.selections.map(s => ({ ...s }));
    sels.forEach(sel => {
      if (!sel.books.some(b => newBooks.includes(b))) return;
      if (this.chaptersOf(sel.books) > this.chaptersOf(newBooks)) {
        const base = sel.baseName || sel.name;
        sel.books = sel.books.filter(b => !newBooks.includes(b));
        sel.name = restName(base);
        sel.baseName = base;
      } else {
        newBooks = newBooks.filter(b => !sel.books.includes(b));
      }
    });
    if (!newBooks.length) return;
    const label = newBooks.length < books.length ? restName(name) : name;
    this.editSt({ selections: [...sels.filter(s => s.books.length), { name: label, baseName: name, books: newBooks, freq }] });
  }

  todayIndex(startDate) {
    const ms = Date.now() - new Date(startDate + "T00:00:00").getTime();
    return Math.max(0, Math.min(364, Math.floor(ms / 86400000)));
  }

  dayChapters(sel, i) {
    const seq = [];
    sel.books.forEach(n => {
      const c = this.BOOKS.find(b => b[0] === n)[1];
      for (let k = 1; k <= c; k++) seq.push({ book: n, ch: k });
    });
    const C = seq.length, total = Math.round(C * sel.freq);
    if (!total) return [];
    const a = Math.floor(i * total / 365), b = Math.floor((i + 1) * total / 365);
    const out = [];
    for (let k = a; k < b; k++) out.push(seq[k % C]);
    return out;
  }

  abbrev(book) {
    if (!this.ABBR) this.ABBR = { "Genesis":"Gn","Exodus":"Ex","Leviticus":"Lv","Numbers":"Nm","Deuteronomy":"Dt","Joshua":"Jos","Judges":"Jdg","Ruth":"Ru","1 Samuel":"1Sm","2 Samuel":"2Sm","1 Kings":"1Kg","2 Kings":"2Kg","1 Chronicles":"1Ch","2 Chronicles":"2Ch","Ezra":"Ezr","Nehemiah":"Neh","Esther":"Est","Job":"Job","Psalms":"Ps","Proverbs":"Prv","Ecclesiastes":"Ecc","Song of Songs":"Sng","Isaiah":"Isa","Jeremiah":"Jer","Lamentations":"Lam","Ezekiel":"Ezk","Daniel":"Dan","Hosea":"Hos","Joel":"Joel","Amos":"Am","Obadiah":"Ob","Jonah":"Jon","Micah":"Mic","Nahum":"Nah","Habakkuk":"Hab","Zephaniah":"Zep","Haggai":"Hag","Zechariah":"Zec","Malachi":"Mal","Matthew":"Mt","Mark":"Mk","Luke":"Lk","John":"Jn","Acts":"Ac","Romans":"Rom","1 Corinthians":"1Co","2 Corinthians":"2Co","Galatians":"Gal","Ephesians":"Eph","Philippians":"Php","Colossians":"Col","1 Thessalonians":"1Th","2 Thessalonians":"2Th","1 Timothy":"1Tm","2 Timothy":"2Tm","Titus":"Ti","Philemon":"Phm","Hebrews":"Heb","James":"Jas","1 Peter":"1Pt","2 Peter":"2Pt","1 John":"1Jn","2 John":"2Jn","3 John":"3Jn","Jude":"Jud","Revelation":"Rev" };
    return this.ABBR[book] || book;
  }

  formatChapters(chs, abbr) {
    if (!chs.length) return "";
    const parts = [];
    let start = chs[0], prev = chs[0];
    const flush = () => {
      const bk = abbr ? this.abbrev(start.book) : (start.book === "Psalms" ? "Psalm" : start.book);
      const one = this.BOOKS.find(b => b[0] === start.book)[1] === 1;
      if (one) parts.push(bk);
      else if (start.ch === prev.ch) parts.push(bk + " " + start.ch);
      else parts.push(bk + " " + start.ch + "–" + prev.ch);
    };
    for (let i = 1; i < chs.length; i++) {
      const c = chs[i];
      if (c.book === prev.book && c.ch === prev.ch + 1) { prev = c; continue; }
      flush(); start = c; prev = c;
    }
    flush();
    return parts.join("; ");
  }

  freqLabel(f) {
    if (f <= 0.5) return "every 2 years";
    if (f === 1) return "once a year";
    if (f === 12) return "once a month";
    if (f === 52) return "once a week";
    return f + "× a year";
  }

  streak(todayIdx) {
    const dayDone = (i) => {
      let sched = 0, chk = 0;
      this.state.selections.forEach((sel, si) => { if (this.dayChapters(sel, i).length) { sched++; if (this.state.checks["d" + i + "s" + si]) chk++; } });
      return sched === 0 ? null : chk === sched;
    };
    let n = 0, i = todayIdx;
    if (dayDone(i) === false) i--;
    for (; i >= 0; i--) { const d = dayDone(i); if (d === false) break; if (d === true) n++; }
    return n;
  }

  dateOf(i) {
    const d = new Date(this.state.startDate + "T00:00:00");
    d.setDate(d.getDate() + i);
    return d;
  }

}

/* ------------------------------------------------------------------ *
 * Daily Word - app layer (Rock)
 * Wraps the verified scheduling core. Render-to-HTML-string functions
 * replace the DC template and renderVals(). No framework.
 * ------------------------------------------------------------------ */

class DailyWord extends DWCore {
  constructor(props) {
    super(props);
    this.saveTimer = null;
    this.saveState = "idle";
    this.root = document.getElementById("dw-root");
    this.toast = document.createElement("div");
    this.toast.id = "dw-toast";
    this.toast.className = "";
    document.body.appendChild(this.toast);
    this.toastTimer = null;
    this.API = "https://api.scripture.api.bible/v1";
    this.TEXT_BIBLE = "78a9f6124f344018-01";
    this.audioQueue = []; this.audioIdx = 0; this.audioEl = null;
    this.audioObjectUrl = null; this.timecodes = null; this.chapterVerses = {};
    this.syncOffset = 0; this.audioChapters = null;
    this.audioBibleId = null; this.audioBibleName = null; this.audioCopyright = "";
    this.timecodesAreSynthetic = false;
    this.LEAD = 0;  // class field in the prototype; the estimate already
                    // accounts for the spoken chapter intro
    this.audioUnlocked = false;
    this.playedAny = false; this.lastSkip = null;
    const st = this.state;
    if (st.readerFont == null) st.readerFont = 18;
    if (st.follow == null) st.follow = true;
    if (st.speed == null) st.speed = 1;
  }

  /* ---------- persistence (the only Rock-facing surface) ---------- */

  setSt(patch) { Object.assign(this.state, patch); this.render(); this.queueSave(); }
  setLocal(patch) { Object.assign(this.state, patch); this.render(); }

  // Core's addSelection() calls this. While editing an existing plan from the
  // setup view, changes are a draft held in memory until "Save my changes";
  // otherwise they persist immediately. Mirrors the prototype's editSt().
  editSt(patch) {
    if (this.state.startDate && this.state.view === "setup") this.setLocal(patch);
    else this.setSt(patch);
  }

  queueSave() {
    clearTimeout(this.saveTimer);
    this.saveState = "pending";
    this.paintSaveNotice();
    this.saveTimer = setTimeout(() => this.save(), 1500);
  }

  save() {
    const s = this.state;
    const payload = {
      selections: s.selections, startDate: s.startDate,
      checks: s.checks, viewDay: s.viewDay, view: s.view,
      readerFont: s.readerFont, follow: s.follow, speed: s.speed
    };
    this.saveState = "saving";
    this.paintSaveNotice();
    fetch(DW_SAVE_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json",
                 "X-Helix-CSRF-Protection": "true" },
      body: JSON.stringify({ plan: JSON.stringify(payload) })
    })
      .then(r => r.text())
      .then(txt => {
        let body = null;
        try { body = JSON.parse(txt); } catch (e) { body = null; }
        this.saveState = (body && body.ok) ? "saved" : "error";
        this.paintSaveNotice();
      })
      .catch(() => { this.saveState = "error"; this.paintSaveNotice(); });
  }

  paintSaveNotice() {
    const el = this.toast;
    if (!el) return;
    const map = {
      idle: "", pending: "", saving: "Saving\u2026",
      saved: "Saved", error: "Could not save \u2014 check your connection"
    };
    const msg = map[this.saveState] || "";
    el.className = (msg ? "is-on" : "") + (this.saveState === "error" ? " is-err" : "");
    if (msg) el.textContent = msg;
    clearTimeout(this.toastTimer);
    if (this.saveState === "saved") {
      this.toastTimer = setTimeout(() => { el.className = ""; }, 1800);
    }
  }

  /* ---------- helpers ---------- */

  esc(s) {
    return String(s == null ? "" : s)
      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  }

  pace(sel) {
    const per = this.chaptersOf(sel.books) * sel.freq / 365;
    return per >= 1
      ? "\u2248" + (Math.round(per * 10) / 10) + " ch/day"
      : "\u2248" + (Math.round(per * 70) / 10) + " ch/week";
  }

  dayLoad(i) {
    return this.state.selections.reduce((a, s) => a + this.dayChapters(s, i).length, 0);
  }

  summaryLine() {
    const total = this.state.selections.reduce(
      (a, s) => a + Math.round(this.chaptersOf(s.books) * s.freq), 0);
    const perDay = total / 365;
    const mins = Math.round(perDay * DW_MIN_PER_CHAPTER);
    if (!total) return "Nothing selected yet.";
    return "About " + (Math.round(perDay * 10) / 10) + " chapters a day ("
      + mins + " min)";
  }

  /* ---------- boot ---------- */

  start() {
    const s = this.state;
    if (s.startDate) s.viewDay = this.todayIndex(s.startDate);
    if (!s.startDate) s.view = "setup";
    if (s.view === "read") s.view = "today";
    this.bind();
    this.render();
    // discover the licensed audio Bible once, then repaint so the dock can
    // appear if the reader is open. Failure is non-fatal: no dock, no audio.
    this.ensureAudioBible().then(() => {
      if (this.paintDock) this.paintDock();
      if (this.state.view === "read") this.render();
    }).catch(() => {});
  }

  bind() {
    this.root.addEventListener("click", (ev) => {
      const el = ev.target.closest("[data-act]");
      if (!el) return;
      // Rock WebForms pages wrap everything in one <form>; an unprevented
      // button click submits it and reloads the page
      ev.preventDefault();
      const act = el.getAttribute("data-act");
      const i = parseInt(el.getAttribute("data-i"), 10);
      this.dispatch(act, i, el);
    });
    this.root.addEventListener("change", (ev) => {
      const el = ev.target.closest("[data-act]");
      if (!el) return;
      this.dispatch(el.getAttribute("data-act"), -1, el);
    });
  }

  dispatch(act, i, el) {
    if (this.readerDispatch && this.readerDispatch(act, i, el)) return;
    const s = this.state;
    const steps = this.FREQ_STEPS;

    if (act === "nav") { this.setLocal({ view: el.getAttribute("data-view") }); return; }

    if (act === "inc" || act === "dec") {
      const sel = s.selections[i];
      const at = Math.max(0, steps.indexOf(sel.freq));
      const next = act === "inc" ? Math.min(steps.length - 1, at + 1) : Math.max(0, at - 1);
      const sels = s.selections.slice();
      sels[i] = Object.assign({}, sel, { freq: steps[next] });
      this.setLocal({ selections: sels });
      return;
    }
    if (act === "remove") {
      const sels = s.selections.slice(); sels.splice(i, 1);
      this.setLocal({ selections: sels });
      return;
    }
    if (act === "preset") {
      const p = this.PRESETS[i];
      this.addSelection(p.name, p.books, 1);
      this.render();
      return;
    }
    if (act === "template") {
      const t = this.TEMPLATES[parseInt(el.value, 10)];
      if (!t) return;
      this.setLocal({ selections: t.sels.map(x => Object.assign({}, x, { books: x.books.slice() })) });
      return;
    }
    if (act === "book") {
      const b = this.BOOKS[parseInt(el.value, 10)];
      if (!b) return;
      this.addSelection(b[0], [b[0]], 1);
      el.value = "";
      this.render();
      return;
    }
    if (act === "startplan") {
      if (!s.selections.length) return;
      const today = new Date();
      const iso = today.getFullYear() + "-"
        + String(today.getMonth() + 1).padStart(2, "0") + "-"
        + String(today.getDate()).padStart(2, "0");
      this.setSt({ startDate: s.startDate || iso, view: "today",
                   viewDay: s.startDate ? s.viewDay : 0 });
      return;
    }
    if (act === "day") {
      const d = Math.max(0, Math.min(364, s.viewDay + i));
      this.setLocal({ viewDay: d });
      return;
    }
    if (act === "check") {
      const key = "d" + s.viewDay + "s" + i;
      const checks = Object.assign({}, s.checks);
      if (checks[key]) delete checks[key]; else checks[key] = true;
      this.setSt({ checks: checks });
      return;
    }
    if (act === "catchup") {
      const key = el.getAttribute("data-key");
      const checks = Object.assign({}, s.checks);
      checks[key] = true;
      this.setSt({ checks: checks });
      return;
    }
    if (act === "editplan") { this.setLocal({ view: "setup" }); return; }
  }

  /* ---------- render ---------- */

  render() {
    const s = this.state;
    const view = s.startDate ? s.view : "setup";
    const wrap = view === "read" ? "dw-wrap-read"
      : view === "today" ? "dw-wrap-narrow" : "dw-wrap";
    const inner = view === "read" ? this.renderRead()
      : view === "today" ? this.renderToday() : this.renderSetup();
    this.root.innerHTML =
      this.renderHeader(view) +
      '<main class="dw-main"><div class="' + wrap + '">' + inner + "</div></main>";
    if (this.paintDock) this.paintDock();
    if (view === "read" && this.paintActiveVerse) this.paintActiveVerse();
    this.paintSaveNotice();
  }

  renderHeader(view) {
    const tabs = this.state.startDate
      ? [["today", "Today"], ["setup", "Edit plan"]]
      : [];
    return '<header class="dw-head"><div class="dw-head-in">'
      + '<div class="dw-brand"><span class="dw-wordmark">Daily Word</span>'
      + '<span class="dw-rule"></span>'
      + '<span class="dw-tag">a year in Scripture</span></div>'
      + '<nav class="dw-nav">'
      + tabs.map(t =>
          '<button type="button" data-act="nav" data-view="' + t[0] + '" class="dw-tab'
          + (view === t[0] ? " is-on" : "") + '">' + t[1] + "</button>").join("")
      + "</nav></div></header>";
  }

  /* ---------- setup ---------- */

  renderSetup() {
    const s = this.state;
    const editing = !!s.startDate;
    const rows = s.selections.map((sel, i) => {
      const C = this.chaptersOf(sel.books);
      const caption = (sel.books.length === 1 ? C + " chapters"
        : sel.books.length + " books \u00b7 " + C + " chapters");
      return '<div class="dw-row">'
        + '<div class="dw-row-main"><div class="dw-secname">' + this.esc(sel.name) + "</div>"
        + '<div class="dw-cap">' + caption + "</div></div>"
        + '<div class="dw-step">'
        + '<button type="button" class="dw-ghost" data-act="dec" data-i="' + i + '" aria-label="Less often">\u2212</button>'
        + '<div class="dw-freq"><div class="dw-freq-n">' + this.freqLabel(sel.freq) + "</div>"
        + '<div class="dw-cap">' + this.pace(sel) + "</div></div>"
        + '<button type="button" class="dw-ghost" data-act="inc" data-i="' + i + '" aria-label="More often">+</button>'
        + "</div>"
        + '<button type="button" class="dw-x" data-act="remove" data-i="' + i + '" aria-label="Remove">\u00d7</button>'
        + "</div>";
    }).join("");

    const used = {};
    s.selections.forEach(x => { used[x.baseName || x.name] = true; });
    const chips = this.PRESETS.map((p, i) =>
      '<button type="button" class="dw-chip' + (used[p.name] ? " is-used" : "") + '" data-act="preset" data-i="'
      + i + '">' + this.esc(p.name) + "</button>").join("");

    const covered = {};
    s.selections.forEach(x => x.books.forEach(b => { covered[b] = true; }));
    const books = '<select class="dw-select" data-act="book">'
      + '<option value="">Or add a single book\u2026</option>'
      + this.BOOKS.map((b, k) =>
          '<option value="' + k + '">' + this.esc(b[0]) + " (" + b[1] + ")"
          + (covered[b[0]] ? " \u2014 in another section" : "") + "</option>").join("")
      + "</select>";

    const tmpl = '<select class="dw-select" data-act="template">'
      + '<option value="">Start from a proven plan\u2026</option>'
      + this.TEMPLATES.map((t, i) =>
          '<option value="' + i + '">' + this.esc(t.name) + "</option>").join("")
      + "</select>";

    return '<section class="dw-hero">'
      + '<div class="dw-eyebrow dw-sage">Build your plan</div>'
      + '<h1 class="dw-h1">Deep in the <i>Word</i></h1>'
      + '<p class="dw-lede">Choose the parts of the Bible you want to read and how '
      + "often. We\u2019ll turn it into a simple day-by-day plan for the next year.</p>"
      + "</section>"
      + '<div class="dw-eyebrow dw-taupe">Your sections</div>'
      + '<div class="dw-card dw-sections">' + (rows || '<div class="dw-row"><div class="dw-empty">'
        + "Nothing chosen yet \u2014 pick a starting point below.</div></div>") + "</div>"
      + '<div class="dw-twoup">' + tmpl + books + "</div>"
      + '<div class="dw-eyebrow dw-taupe">Add a section</div>'
      + '<div class="dw-chips">' + chips + "</div>"
      + '<div class="dw-actionbar"><span class="dw-summary">' + this.summaryLine() + "</span>"
      + '<button type="button" class="dw-primary" data-act="startplan">'
      + (editing ? "Save my changes" : "Start my plan") + "</button></div>";
  }

  /* ---------- today ---------- */

  renderToday() {
    const s = this.state;
    const i = s.viewDay;
    const d = this.dateOf(i);
    const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
    const MON = ["January", "February", "March", "April", "May", "June", "July",
                 "August", "September", "October", "November", "December"];
    const isToday = i === this.todayIndex(s.startDate);
    const eyebrow = (isToday ? "Today" : "Day " + (i + 1)) + " \u00b7 " + DAYS[d.getDay()];

    const rows = s.selections.map((sel, si) => {
      const chs = this.dayChapters(sel, i);
      if (!chs.length) return "";
      const done = !!s.checks["d" + i + "s" + si];
      const delay = Math.min(si * 50, 200);
      return '<div class="dw-read' + (done ? " is-done" : "") + '" data-act="check" data-i="' + si
        + '" style="animation-delay:' + delay + 'ms">'
        + '<span class="dw-check' + (done ? " is-on" : "") + '">\u2713</span>' 
        + '<span class="dw-read-main"><span class="dw-stream">' + this.esc(sel.name) + "</span>"
        + '<span class="dw-passage">' + this.esc(this.formatChapters(chs)) + "</span>"
        + '<span class="dw-cap">' + chs.length + (chs.length === 1 ? " chapter" : " chapters")
        + "</span></span>"
        + '<button type="button" class="dw-readpill" data-act="readone" data-i="' + si + '">Read</button>'
        + "</div>";
    }).join("");

    const sched = s.selections.filter(sel => this.dayChapters(sel, i).length).length;
    const donen = s.selections.filter((sel, si) =>
      this.dayChapters(sel, i).length && s.checks["d" + i + "s" + si]).length;
    const pct = sched ? Math.round(donen / sched * 100) : 100;

    const todayIdx = this.todayIndex(s.startDate);
    const catchup = [];
    for (let k = 0; k < todayIdx; k++) {
      s.selections.forEach((sel, si) => {
        const chs = this.dayChapters(sel, k);
        if (chs.length && !s.checks["d" + k + "s" + si])
          catchup.push({ k: k, si: si, ref: this.formatChapters(chs), name: sel.name });
      });
    }

    let missed = "";
    if (catchup.length) {
      missed = '<div class="dw-eyebrow dw-taupe">Catch up \u00b7 ' + catchup.length + "</div>"
        + '<div class="dw-catch">'
        + catchup.slice(0, 8).map(c => {
            const dt = this.dateOf(c.k);
            return '<div class="dw-cu" data-act="catchup" data-key="d' + c.k + "s" + c.si + '">'
              + '<span class="dw-check dw-small"></span>'
              + '<span><span class="dw-cu-ref">' + this.esc(c.ref) + "</span>"
              + '<span class="dw-cap">' + this.esc(c.name) + " \u00b7 "
              + MON[dt.getMonth()].slice(0, 3) + " " + dt.getDate() + "</span></span></div>";
          }).join("")
        + (catchup.length > 8
            ? '<div class="dw-cap dw-more">\u2026and ' + (catchup.length - 8) + " more</div>" : "")
        + "</div>";
    }

    return '<section class="dw-masthead">'
      + '<div class="dw-mast-l">'
      + '<div class="dw-eyebrow dw-sage">' + eyebrow + "</div>"
      + '<h1 class="dw-date">' + MON[d.getMonth()] + " " + d.getDate() + "</h1>"
      + "</div>"
      + '<div class="dw-daynav">'
      + '<button type="button" class="dw-round" data-act="day" data-i="-1" title="Previous day">\u2190</button>'
      + '<button type="button" class="dw-round" data-act="day" data-i="1" title="Next day">\u2192</button>'
      + "</div></section>"
      + '<div class="dw-subrow"><span class="dw-cap">Day ' + (i + 1) + " of 365</span>"
      + (rows ? '<button type="button" class="dw-chip" data-act="openday">Read today\u2019s passages</button>' : "")
      + "</div>"
      + '<div class="dw-card">' + (rows
        || '<div class="dw-row"><div class="dw-rest">A day of rest \u2014 nothing scheduled today.</div></div>')
      + "</div>"
      + '<div class="dw-stats">'
      + '<div class="dw-stat"><div class="dw-num">' + this.streak(todayIdx) + '</div><div class="dw-slab">Day streak</div></div>'
      + '<div class="dw-stat"><div class="dw-num">' + pct + '%</div><div class="dw-slab">Caught up</div></div>'
      + '<div class="dw-stat"><div class="dw-num">' + this.dayLoad(i) + '</div><div class="dw-slab">Chapters today</div></div>'
      + "</div>"
      + '<div class="dw-bar"><span style="width:' + pct + '%"></span></div>'
      + missed;
  }

  // Build the <audio> element and its handlers. Split out of playAt() so a
  // click handler can create and unlock it synchronously, before any await.
  ensureAudioEl() {
    if (this.audioEl) return this.audioEl;
    const el = new Audio();
    el.preload = "auto";
    el.onended = () => {
      if (this.priming) return;   // the silent unlock clip, not a real chapter
      const cur = (this.audioQueue || [])[this.audioIdx];
      const nxt = (this.audioQueue || [])[this.audioIdx + 1];
      if (cur && (!nxt || nxt.bi !== cur.bi)) this.markBlockRead(cur.bi);
      this.playAt(this.audioIdx + 1);
    };
    el.onerror = () => {
      if (this.priming) return;
      const codes = { 1: "aborted", 2: "network error", 3: "decode failed",
                      4: "format not supported" };
      const c = el.error && el.error.code;
      this.lastSkip = "media " + (codes[c] || ("error " + c));
      console.warn("Daily Word audio: element error -", this.lastSkip,
        "on", el.src && el.src.slice(0, 90));
      this.playAt(this.audioIdx + 1);
    };
    el.onloadedmetadata = () => { if (!this.priming) this.buildSynthTimecodes(); };
    el.ontimeupdate = () => {
      const tcs = this.timecodes;
      if (!tcs || !tcs.length) return;
      const t = el.currentTime + (this.LEAD || 0) + (this.syncOffset || 0);
      const hit = tcs.find(x => t >= x.a && t < x.b)
        || (t >= tcs[tcs.length - 1].b ? tcs[tcs.length - 1] : null);
      if (hit && hit.vid && hit.vid !== this.state.activeVid)
        this.setState({ activeVid: hit.vid });
    };
    this.audioEl = el;
    return el;
  }

  // Must run inside the click, synchronously. Playing a moment of silence
  // marks the element as user-initiated, so a later src swap can start
  // without a fresh gesture.
  unlockAudio() {
    const el = this.ensureAudioEl();
    if (this.audioUnlocked) return;
    this.audioUnlocked = true;
    this.priming = true;
    try {
      el.src = "data:audio/wav;base64,UklGRrQBAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YZABAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA";
      const p = el.play();
      if (p && p.then) p.then(() => { el.pause(); }).catch(() => {});
    } catch (e) { /* playAt reports the real failure */ }
  }

  debugTiming() {
    return {
      cid: this.currentCid,
      now: this.audioEl && this.audioEl.currentTime,
      activeVid: this.state.activeVid,
      syncOffset: this.syncOffset,
      duration: this.audioEl && this.audioEl.duration,
      rateSamples: this.rateSamples,
      firstSegs: (this.timecodes || []).slice(0, 4),
      vids: (this.chapterVerses[this.currentCid] || {}).vids
    };
  }

  /* ---------- ported verbatim from the v5 prototype ---------- */

  apiHeaders() { return { headers: { "api-key": DW_BIBLE_KEY } }; }


  osis(book) {
    if (!this.OSIS) this.OSIS = { "Genesis":"GEN","Exodus":"EXO","Leviticus":"LEV","Numbers":"NUM","Deuteronomy":"DEU","Joshua":"JOS","Judges":"JDG","Ruth":"RUT","1 Samuel":"1SA","2 Samuel":"2SA","1 Kings":"1KI","2 Kings":"2KI","1 Chronicles":"1CH","2 Chronicles":"2CH","Ezra":"EZR","Nehemiah":"NEH","Esther":"EST","Job":"JOB","Psalms":"PSA","Proverbs":"PRO","Ecclesiastes":"ECC","Song of Songs":"SNG","Isaiah":"ISA","Jeremiah":"JER","Lamentations":"LAM","Ezekiel":"EZK","Daniel":"DAN","Hosea":"HOS","Joel":"JOL","Amos":"AMO","Obadiah":"OBA","Jonah":"JON","Micah":"MIC","Nahum":"NAM","Habakkuk":"HAB","Zephaniah":"ZEP","Haggai":"HAG","Zechariah":"ZEC","Malachi":"MAL","Matthew":"MAT","Mark":"MRK","Luke":"LUK","John":"JHN","Acts":"ACT","Romans":"ROM","1 Corinthians":"1CO","2 Corinthians":"2CO","Galatians":"GAL","Ephesians":"EPH","Philippians":"PHP","Colossians":"COL","1 Thessalonians":"1TH","2 Thessalonians":"2TH","1 Timothy":"1TI","2 Timothy":"2TI","Titus":"TIT","Philemon":"PHM","Hebrews":"HEB","James":"JAS","1 Peter":"1PE","2 Peter":"2PE","1 John":"1JN","2 John":"2JN","3 John":"3JN","Jude":"JUD","Revelation":"REV" };
    return this.OSIS[book];
  }

  labelForCid(cid) {
    const [osis, ch] = String(cid).split(".");
    const book = Object.keys(this.OSIS || {}).find(k => this.OSIS[k] === osis) || osis;
    const single = (this.BOOKS.find(b => b[0] === book) || [])[1] === 1;
    const name = book === "Psalms" ? "Psalm" : book;
    return single ? name : name + " " + ch;
  }

  chapterIds(chs) {
    const seen = new Set(), out = [];
    (chs || []).forEach(c => { const id = this.osis(c.book) + "." + c.ch; if (!seen.has(id)) { seen.add(id); out.push(id); } });
    return out;
  }

  async openDay(dayIdx, focusBlock) {
    this.stopAudio();
    const st = this.state;
    const blocks = st.selections.map((sel, si) => {
      const chs = this.dayChapters(sel, dayIdx);
      if (!chs.length) return null;
      return { si, key: "d" + dayIdx + "s" + si, name: sel.name, ref: this.formatChapters(chs), chapterIds: this.chapterIds(chs), paras: [] };
    }).filter(Boolean);
    const prev = st.view === "read" ? (st.prevView || "today") : st.view;
    // focusBlock arrives as a selection index (from the Today row's Read
    // pill); data-block is an index into the FILTERED blocks array. Convert
    // here, or any unscheduled stream earlier in the list shifts the target.
    this.focusBlock = focusBlock == null ? null
      : blocks.findIndex(b => b.si === focusBlock);
    if (this.focusBlock === -1) this.focusBlock = null;
    this.startBlock = this.focusBlock;
    this.chapterVerses = {};
    this.setSt({ prevView: prev, view: "read", viewDay: dayIdx, day: { idx: dayIdx, blocks, loading: true, error: null, copyright: "" }, activeVid: null });
    this.loadDayText();
  }

  // Some editions deliver a chapter's opening text with NO verseId on the
  // spans (observed on MAT.1: verse 1 arrived unattributed, so the verse
  // list began at v2 and the follow-along highlight started there). Leading
  // unattributed spans belong to verse 1 whenever verse 1 is otherwise
  // absent; orphans elsewhere continue the verse they follow. This also
  // returns their characters to the timing table, which had been ignoring
  // that narration time entirely.
  repairVerseIds(parsed, cid) {
    let firstVid = null;
    for (const p of parsed) {
      for (const sp of p.spans) if (sp.vid) { firstVid = sp.vid; break; }
      if (firstVid) break;
    }
    if (!firstVid) return;
    const vnum = firstVid.split(".")[2];
    if (vnum !== "1") {
      const v1 = cid + ".1";
      let leading = true;
      parsed.forEach(p => p.spans.forEach(sp => {
        if (sp.vid) { leading = false; return; }
        if (leading) sp.vid = v1;
      }));
    }
    let last = null;
    parsed.forEach(p => p.spans.forEach(sp => {
      if (sp.vid) last = sp.vid;
      else if (last) sp.vid = last;
    }));
  }

  async loadDayText() {
    const day = this.state.day;
    if (!day) return;
    try {
      const blocks = day.blocks.map(b => ({ ...b, paras: [] }));
      let copyright = "";
      for (const b of blocks) {
        for (const cid of b.chapterIds) {
          const r = await fetch(this.API + "/bibles/" + this.TEXT_BIBLE + "/chapters/" + cid + "?content-type=json&include-verse-numbers=false&include-titles=false&include-chapter-numbers=false", this.apiHeaders());
          const j = await r.json();
          if (!j.data) continue;
          const parsed = this.parseContent(j.data.content);
          this.repairVerseIds(parsed, cid);
          // keep verse order + length per chapter so audio can follow along even
          // when the audio edition publishes no verse timecodes
          const vids = [], lens = {};
          parsed.forEach(p => p.spans.forEach(sp => {
            if (!sp.vid) return;
            if (lens[sp.vid] === undefined) { vids.push(sp.vid); lens[sp.vid] = 0; }
            lens[sp.vid] += sp.t.length;
          }));
          this.chapterVerses[cid] = { vids, lens };
          b.paras = b.paras.concat(parsed);
          copyright = j.data.copyright || copyright;
        }
      }
      if (this.state.view !== "read") return;
      this.setState({ day: { ...day, blocks, loading: false, copyright: (copyright || "").trim() } }, () => {
        if (this.focusBlock != null) { this.scrollToEl('[data-block="' + this.focusBlock + '"]', 0.12); this.focusBlock = null; }
      });
    } catch (e) {
      this.setState({ day: { ...day, loading: false, error: "Couldn't reach the Bible API. Check your connection and try again." } });
    }
  }

  parseContent(content) {
    const paras = [];
    const collect = (items, out) => {
      (items || []).forEach(it => {
        if (it.type === "text") { if (it.text) out.push({ vid: (it.attrs && it.attrs.verseId) || "", t: it.text }); }
        else if (it.items) collect(it.items, out);
      });
    };
    (content || []).forEach(node => {
      const out = [];
      collect(node.items, out);
      if (out.length) paras.push({ spans: out });
    });
    return paras;
  }

  scrollParentOf(el) {
    let n = el.parentElement;
    while (n) {
      const o = getComputedStyle(n).overflowY;
      if ((o === "auto" || o === "scroll" || o === "overlay") && n.scrollHeight > n.clientHeight + 1) return n;
      n = n.parentElement;
    }
    const de = document.scrollingElement || document.documentElement;
    return de.scrollHeight > de.clientHeight + 1 ? de : null;
  }

  scrollToEl(sel, ratio) {
    const el = document.querySelector(sel);
    if (!el) return;
    const box = this.scrollParentOf(el);
    if (!box) return;
    const isDoc = box === document.scrollingElement || box === document.documentElement || box === document.body;
    const viewH = isDoc ? window.innerHeight : box.clientHeight;
    const cur = isDoc ? (window.scrollY || box.scrollTop) : box.scrollTop;
    const r = el.getBoundingClientRect();
    const boxTop = isDoc ? 0 : box.getBoundingClientRect().top;
    // centre the verse itself; a ratio pins its top that far down instead
    const offset = ratio == null ? (viewH - r.height) / 2 : viewH * ratio;
    const to = Math.max(0, cur + (r.top - boxTop) - offset);
    this.tweenScroll(box, isDoc, to);
  }

  tweenScroll(box, isDoc, to) {
    const from = isDoc ? (window.scrollY || box.scrollTop) : box.scrollTop;
    if (Math.abs(to - from) < 2) return;
    const set = (y) => { if (isDoc) window.scrollTo(0, y); box.scrollTop = y; };
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { set(to); return; }
    if (this.scrollRaf) cancelAnimationFrame(this.scrollRaf);
    const ease = (t) => 1 - Math.pow(1 - t, 3);
    const dur = 420, t0 = performance.now();
    const step = (now) => {
      const p = Math.min(1, (now - t0) / dur);
      set(from + (to - from) * ease(p));
      if (p < 1) this.scrollRaf = requestAnimationFrame(step); else this.scrollRaf = null;
    };
    this.scrollRaf = requestAnimationFrame(step);
  }

  async ensureAudioBible() {
    if (this.audioReady) return this.audioBibleId;
    try {
      const r = await fetch(this.API + "/audio-bibles?language=eng&include-full-details=true", this.apiHeaders());
      const j = await r.json();
      const list = j.data || [];
      const pick = list.find(b => /niv/i.test(b.abbreviation || "") || /niv/i.test(b.abbreviationLocal || "")) || list[0];
      this.audioBibleId = pick ? pick.id : null;
      this.audioBibleName = pick ? (pick.abbreviationLocal || pick.abbreviation || pick.name || "") : "";
      this.audioBibleFull = pick ? (pick.name || "") : "";
      this.audioBibleCopyright = pick ? (pick.copyright || "") : "";
      if (this.audioBibleId) {
        const rb = await fetch(this.API + "/audio-bibles/" + this.audioBibleId + "/books?include-chapters=true", this.apiHeaders());
        const jb = await rb.json();
        const set = new Set();
        (jb.data || []).forEach(bk => (bk.chapters || []).forEach(c => set.add(c.id)));
        this.audioChapters = set;
      }
    } catch (e) { this.audioBibleId = null; }
    this.audioReady = true;
    return this.audioBibleId;
  }

  audioIdsFor(ids) {
    if (!this.audioChapters) return ids || [];
    return (ids || []).filter(id => this.audioChapters.has(id));
  }

  dayAudioQueue() {
    const day = this.state.day;
    if (!day) return [];
    const q = [];
    day.blocks.forEach((b, bi) => this.audioIdsFor(b.chapterIds).forEach(cid => q.push({ cid, bi })));
    return q;
  }

  async startAudio() {
    if (!this.state.day) return;
    this.setState({ audio: { state: "loading", i: 0, n: 0 } });
    const id = await this.ensureAudioBible();
    if (!id) return this.setState({ audio: { state: "error", error: "No audio Bible is licensed on this key yet." } });
    const q = this.dayAudioQueue();
    if (!q.length) return this.setState({ audio: { state: "none", i: 0, n: 0 } });
    this.audioQueue = q;
    // If the person arrived via a passage's Read pill, play starts THERE -
    // not at the day's first chapter with a scroll yank back to the top.
    let start = 0;
    if (this.startBlock != null) {
      const k = q.findIndex(c => c.bi === this.startBlock);
      if (k > -1) start = k;
    }
    this.playAt(start);
  }

  tcSeconds(v) {
    if (v == null) return 0;
    if (typeof v === "number") return v;
    const s = String(v);
    if (s.indexOf(":") > -1) return s.split(":").reduce((a, p) => a * 60 + parseFloat(p), 0);
    return parseFloat(s) || 0;
  }

  async playAt(i) {
    const q = this.audioQueue || [];
    if (i >= q.length) {
      if (this.playedAny) {
        if (q.length) this.markBlockRead(q[q.length - 1].bi);
        return this.setState({ audio: { state: "done", i: q.length - 1, n: q.length }, activeVid: null });
      }
      const why = this.lastSkip || "no playable audio";
      console.warn("Daily Word audio: every chapter skipped -", why);
      return this.setState({ audio: { state: "error",
        error: "Audio unavailable (" + why + ")" }, activeVid: null });
    }
    this.audioIdx = i;
    this.setState({ audio: { state: "loading", i, n: q.length } });
    try {
      const r = await fetch(this.API + "/audio-bibles/" + this.audioBibleId + "/chapters/" + q[i].cid, this.apiHeaders());
      const j = await r.json().catch(() => null);
      const url = j && j.data && j.data.resourceUrl;
      if (!url) {
        this.lastSkip = "HTTP " + r.status
          + (j && j.error ? " " + j.error : "")
          + (j && j.message ? " " + j.message : "");
        console.warn("Daily Word audio: no resourceUrl for", q[i].cid,
          "-", this.lastSkip, j);
        return this.playAt(i + 1);
      }
      if (j.data.copyright) this.audioBibleCopyright = j.data.copyright;
      this.timecodes = (j.data.timecodes || []).map(t => ({ a: this.tcSeconds(t.start), b: this.tcSeconds(t.end), vid: t.verseId }));
      this.estimated = false;
      this.currentCid = q[i].cid;
      this.syncOffset = 0;
      const playUrl = url;   // stream direct; the console diagnostic proved
                             // this exact form plays. No blob step.
      this.ensureAudioEl();
      this.priming = false;
      this.audioEl.src = playUrl;
      this.applySpeed(this.state.speed || 1);
      await this.audioEl.play();
      this.playedAny = true;
      this.setState({ audio: { state: "playing", i, n: q.length } });
    } catch (e) {
      const name = (e && e.name) || "";
      const msg = name === "NotAllowedError"
        ? "Tap play once more to allow sound."
        : name === "TypeError" || name === "NetworkError"
          ? "Could not download the audio for this chapter."
          : "Couldn\u2019t play this chapter \u2014 press play to try again.";
      console.warn("Daily Word audio:", name, e && e.message);
      this.setState({ audio: { state: "error", error: msg } });
    }
  }

  introSeconds(cid) {
    if (!cid) return 3.4;
    const parts = String(cid).split(".");
    const osis = parts[0];
    const book = Object.keys(this.OSIS || {}).find(k => this.OSIS[k] === osis) || "";
    let words = (book.split(/\s+/).filter(Boolean).length || 1) + 2; // + "chapter" + number
    // chapter 1 usually opens with the full announced title ("The Gospel
    // according to Matthew"), not just the book name
    if (parts[1] === "1") words += 3;
    return 1.6 + words * 0.62;
  }

  buildSynthTimecodes() {
    if (this.timecodes && this.timecodes.length) return;
    const cv = this.chapterVerses && this.chapterVerses[this.currentCid];
    const D = this.audioEl && this.audioEl.duration;
    if (!cv || !cv.vids.length || !D || !isFinite(D)) return;
    // Each verse boundary also costs a beat of narrator pause - one breath
    // per verse, a slightly longer one at poetic stanzas. Weighting by
    // chars-plus-a-constant moves time toward short verses, which is where
    // uniform char-rate drifts worst (psalm lines, genealogies).
    const PAUSE = 12; // char-equivalents per verse (~0.8s at typical pace)
    const w = {};
    let total = 0;
    cv.vids.forEach(v => { w[v] = (cv.lens[v] || 1) + PAUSE; total += w[v]; });
    // The narrator announces the book and chapter before verse 1. Hold verse 1
    // through that intro, clamped so a short chapter can't spend most of its
    // runtime on the heading.
    const baseT0 = Math.min(this.introSeconds(this.currentCid), D * 0.14);
    const isCh1 = String(this.currentCid).split(".")[1] === "1";
    // MEASURED intro, not guessed: chapters already played this session
    // calibrate the narrator's pace (chars/sec). If this file is longer than
    // its text warrants at that pace, the excess IS the intro/outro - music,
    // announced titles, whatever this edition does. Guessing kept failing on
    // new-book openings; the file's own length does not.
    const rawChars = cv.vids.reduce((n, v) => n + (cv.lens[v] || 1), 0);
    let t0 = baseT0, outro = 0;
    const samples = this.rateSamples || [];
    if (samples.length) {
      const sorted = samples.slice().sort((x, y) => x - y);
      const rate = sorted[Math.floor(sorted.length / 2)];
      const excess = D - baseT0 - rawChars / rate;
      if (excess > 3) {
        t0 = Math.min(baseT0 + excess * 0.75, D * 0.5);
        outro = Math.min(excess * 0.25, D * 0.2);
      }
    }
    // The verses share what remains AFTER the intro. Distributing the full
    // duration from t0 allocated intro+D seconds of timeline to a D-second
    // file, so boundaries ran progressively late - about the whole intro by
    // the last verse. This is the "lags behind by the end of the chapter" bug.
    const body = D - t0 - outro;
    if (body <= 0) return;
    // With no calibration yet (first chapter of the session), fall back to
    // absorbing new-book opening uncertainty into verse 1's window.
    if (!samples.length && isCh1 && body > 20 && cv.vids.length > 1) {
      const extraSecs = Math.min(6, body * 0.08);
      const extraW = total * extraSecs / Math.max(body - extraSecs, 1);
      w[cv.vids[0]] += extraW;
      total += extraW;
    }
    // Mid-book chapters are the cleanest pace signal - record them for the
    // chapters that follow (keep the last 8).
    if (!isCh1) {
      this.rateSamples = (this.rateSamples || []).concat(rawChars / body).slice(-8);
    }
    let t = t0;
    this.timecodes = cv.vids.map(v => {
      const dur = body * w[v] / total;
      const seg = { a: t, b: t + dur, vid: v };
      t += dur;
      return seg;
    });
    this.estimated = true;
    try {
      const s0 = this.timecodes[0], s1 = this.timecodes[1];
      console.info("DW timing", this.currentCid,
        "D=" + D.toFixed(1) + "s",
        "t0=" + t0.toFixed(1), "outro=" + outro.toFixed(1),
        "rate=" + (samples.length ? (samples.slice().sort((x,y)=>x-y)[Math.floor(samples.length/2)]).toFixed(1) : "none"),
        "v1=[" + s0.a.toFixed(1) + "-" + s0.b.toFixed(1) + "]",
        s1 ? "v2=[" + s1.a.toFixed(1) + "-" + s1.b.toFixed(1) + "]" : "",
        "vids=" + cv.vids.slice(0, 3).join(","));
    } catch (e) {}
  }

  markBlockRead(bi) {
    const day = this.state.day;
    if (!day || !day.blocks[bi]) return;
    const key = day.blocks[bi].key;
    if (this.state.checks[key]) return;
    this.setSt({ checks: { ...this.state.checks, [key]: true } });
  }

  applySpeed(rate) {
    if (!this.audioEl) return;
    // keep the narrator's pitch natural at speed
    this.audioEl.preservesPitch = true;
    this.audioEl.mozPreservesPitch = true;
    this.audioEl.webkitPreservesPitch = true;
    this.audioEl.playbackRate = rate || 1;
  }

  pauseAudio() { if (this.audioEl) this.audioEl.pause(); this.setState(s => ({ audio: { ...(s.audio || {}), state: "paused" } })); }

  resumeAudio() { if (this.audioEl) this.audioEl.play(); this.setState(s => ({ audio: { ...(s.audio || {}), state: "playing" } })); }

  stopAudio() {
    if (this.audioEl) { this.audioEl.pause(); this.audioEl.removeAttribute("src"); }
    if (this.audioObjectUrl) { URL.revokeObjectURL(this.audioObjectUrl); this.audioObjectUrl = null; }
    this.audioQueue = null;
    this.timecodes = null;
    this.setState({ audio: null, activeVid: null, queueOpen: false });
  }
}

/* ---------- reader + audio layer, appended to DailyWord ---------- */

Object.assign(DailyWord.prototype, {

  /* setState with surgical paths. A full innerHTML rebuild on every verse
     change would destroy scroll position and make follow-along unusable, so
     activeVid / audio / queueOpen updates patch the DOM in place instead. */
  setState(patch, cb) {
    const p = (typeof patch === "function") ? patch(this.state) : patch;
    if (!p) { if (cb) cb(); return; }
    const keys = Object.keys(p);
    Object.assign(this.state, p);
    const surgical = keys.every(k =>
      k === "activeVid" || k === "audio" || k === "queueOpen");
    if (surgical && this.state.view === "read") {
      if (keys.indexOf("activeVid") > -1) this.paintActiveVerse();
      if (keys.indexOf("audio") > -1 || keys.indexOf("queueOpen") > -1) this.paintDock();
      if (cb) cb();
      return;
    }
    this.render();
    if (cb) cb();
  },

  /* ---------- reader view ---------- */

  renderRead() {
    const s = this.state, day = s.day;
    if (!day) return "";
    const d = this.dateOf(day.idx);
    const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
    const MON = ["January", "February", "March", "April", "May", "June", "July",
                 "August", "September", "October", "November", "December"];
    const isToday = day.idx === this.todayIndex(s.startDate);
    const eyebrow = (isToday ? "Today" : "Day " + (day.idx + 1)) + " \u00b7 " + DAYS[d.getDay()];
    const refs = day.blocks.map(b => b.ref).join(" \u00b7 ");

    let body = "";
    if (day.loading) {
      body = '<div class="dw-loading">Loading today\u2019s passages\u2026</div>';
    } else if (day.error) {
      body = '<div class="dw-err">' + this.esc(day.error) + "</div>";
    } else {
      body = day.blocks.map((b, bi) => {
        const done = !!s.checks[b.key];
        const paras = b.paras.map(p =>
          '<p class="dw-para">' + p.spans.map(sp =>
            '<span data-verse="' + this.esc(sp.vid) + '" data-act="resync"'
            + ' title="Tap to resync the audio here">' + this.esc(sp.t) + "</span>"
          ).join("") + "</p>").join("");
        return '<div class="dw-block" data-block="' + bi + '">'
          + '<div class="dw-block-head"><div class="dw-block-l">'
          + '<div class="dw-stream">' + this.esc(b.name) + "</div>"
          + '<div class="dw-block-ref">' + this.esc(b.ref) + "</div></div>"
          + '<button type="button" class="dw-mark' + (done ? " is-on" : "") + '" data-act="mark" data-i="'
          + bi + '">' + (done ? "\u2713 Read" : "Mark read") + "</button>"
          + "</div>" + paras + "</div>";
      }).join("");
    }

    const notes = [day.copyright, this.audioCopyright,
      this.timecodesAreSynthetic ? "Audio timing is estimated." : "",
      "If it drifts, tap the verse being read to resync."]
      .filter(Boolean).map(x => '<div class="dw-note">' + this.esc(x) + "</div>").join("");

    return '<button type="button" class="dw-back" data-act="back">\u2190 Back</button>'
      + '<div class="dw-read-head"><div class="dw-mast-l">'
      + '<div class="dw-eyebrow dw-sage">' + eyebrow + "</div>"
      + '<h1 class="dw-read-title">' + MON[d.getMonth()] + " " + d.getDate() + "</h1>"
      + '<div class="dw-cap dw-read-sub">' + this.esc(refs) + "</div></div>"
      + '<div class="dw-fontctl">'
      + '<button type="button" class="dw-fs dw-fs-sm" data-act="font" data-i="-1" title="Smaller text">A</button>'
      + '<button type="button" class="dw-fs dw-fs-lg" data-act="font" data-i="1" title="Larger text">A</button>'
      + "</div></div>"
      + '<div class="dw-sagerule"></div>'
      + '<div class="dw-passages" style="font-size:' + (s.readerFont || 18) + 'px">'
      + body + "</div>"
      + notes
      + '<div class="dw-markday"><button type="button" class="dw-primary" data-act="markday">Mark the day read</button></div>';
  },

  /* ---------- glass audio dock ---------- */

  renderDock() {
    const s = this.state;
    if (s.view !== "read" || !s.day || !this.audioBibleId) return "";
    const dayQ = this.dayAudioQueue();
    if (!dayQ.length) return "";
    const q = (this.audioQueue && this.audioQueue.length) ? this.audioQueue : dayQ;
    const a = s.audio || { state: "idle", i: 0, n: q.length };
    const cur = q[a.i] || {};
    const playing = a.state === "playing";
    const label = this.labelForCid(cur.cid || "") || "Today\u2019s reading";
    const dayChapters = s.day.blocks.reduce((n, b) => n + b.chapterIds.length, 0);
    const status = a.state === "loading" ? "Loading audio\u2026"
      : a.state === "error" ? (a.error || "Audio unavailable")
      : a.state === "none" ? "No audio for these chapters yet"
      : a.state === "done" ? "Finished \u00b7 " + (this.audioBibleName || "")
      : a.state === "idle"
        ? (dayQ.length < dayChapters
            ? "Audio for " + dayQ.length + " of " + dayChapters + " chapters"
              + (this.audioBibleName ? " \u00b7 " + this.audioBibleName : "")
            : (this.audioBibleName ? "Audio \u00b7 " + this.audioBibleName : "Ready"))
      : "Chapter " + (a.i + 1) + " of " + (a.n || q.length)
        + (this.audioBibleName ? " \u00b7 " + this.audioBibleName : "");

    const live = s.audio && s.audio.state !== "idle";
    const rows = q.map((c, k) => {
      const on = live && k === a.i;
      const blk = s.day.blocks[c.bi] || {};
      return '<div class="dw-qrow' + (on ? " is-on" : "") + '" data-act="jump" data-i="' + k + '">'
        + '<span class="dw-qglyph">' + (on ? (playing ? "\u275a\u275a" : "\u25b6") : (k + 1)) + "</span>"
        + '<span class="dw-qref">' + this.esc(this.labelForCid(c.cid)) + "</span>"
        + '<span class="dw-qstream">' + this.esc(blk.name || "") + "</span></div>";
    }).join("");

    const queue = s.queueOpen
      ? '<div class="dw-queue"><div class="dw-qhead">Today \u00b7 ' + q.length
        + ' chapters<span class="dw-qcount">Chapter ' + (a.i + 1) + " of " + q.length
        + "</span></div>" + rows + "</div>"
      : "";

    return '<div class="dw-dockwrap">' + queue + '<div class="dw-dock">'
      + '<button type="button" class="dw-play" data-act="playpause">'
      + (playing ? "\u275a\u275a" : "\u25b6") + "</button>"
      + '<div class="dw-dock-l"><div class="dw-dock-ref">' + this.esc(label) + "</div>"
      + '<div class="dw-dock-status">' + this.esc(status) + "</div></div>"
      + '<div class="dw-chips-row">'
      + '<button type="button" class="dw-dchip dw-ico" data-act="prevch" title="Previous chapter">\u2039</button>'
      + '<button type="button" class="dw-dchip dw-ico" data-act="nextch" title="Next chapter">\u203a</button>'
      + '<button type="button" class="dw-dchip' + (s.speed !== 1 ? " is-on" : "") + '" data-act="speed">'
      + (s.speed || 1) + "\u00d7</button>"
      + '<button type="button" class="dw-dchip' + (s.follow ? " is-on" : "") + '" data-act="follow" title="'
      + (s.follow ? 'Following the narration \u2014 tap to stop' : 'Tap to follow the narration') + '">'
      + (s.follow ? '\u2713 Follow' : 'Follow') + '</button>'
      + '<button type="button" class="dw-dchip dw-ico' + (s.queueOpen ? " is-on" : "") + '" data-act="queue" title="Queue">'
      + (s.queueOpen ? "\u2304" : "\u2303") + "</button>"
      + '<button type="button" class="dw-dchip dw-ico" data-act="stopaudio" title="Stop">\u00d7</button>'
      + "</div></div></div>";
  },

  paintDock() {
    let host = document.getElementById("dw-dock-host");
    if (!host) {
      host = document.createElement("div");
      host.id = "dw-dock-host";
      document.body.appendChild(host);
    }
    const html = this.state.view === "read" ? this.renderDock() : "";
    const wasVisible = !!host.innerHTML;
    host.innerHTML = html;
    if (html && wasVisible) host.classList.add("dw-settled");
    if (!html) host.classList.remove("dw-settled");
    if (!host._bound) {
      host.addEventListener("click", (ev) => {
        const el = ev.target.closest("[data-act]");
        if (!el) return;
        ev.preventDefault();
        this.dispatch(el.getAttribute("data-act"),
          parseInt(el.getAttribute("data-i"), 10), el);
      });
      host._bound = true;
    }
  },

  paintActiveVerse() {
    const vid = this.state.activeVid;
    const prev = this.root.querySelectorAll(".dw-verse-on");
    for (let k = 0; k < prev.length; k++) prev[k].classList.remove("dw-verse-on");
    if (!vid) return;
    const els = this.root.querySelectorAll('[data-verse="' + vid + '"]');
    if (!els.length) return;
    for (let k = 0; k < els.length; k++) els[k].classList.add("dw-verse-on");
    if (this.state.follow) {
      requestAnimationFrame(() => this.scrollToEl('[data-verse="' + vid + '"]'));
    }
  },

  /* ---------- reader dispatch ---------- */

  readerDispatch(act, i, el) {
    const s = this.state;
    if (act === "openday") { this.openDay(s.viewDay, null); this.paintDock(); return true; }
    if (act === "readone") { this.openDay(s.viewDay, i); this.paintDock(); return true; }
    if (act === "back") {
      this.stopAudio();
      this.setSt({ view: s.prevView || "today" });
      this.paintDock();
      return true;
    }
    if (act === "font") {
      const n = Math.max(14, Math.min(28, (s.readerFont || 18) + i * 2));
      this.setSt({ readerFont: n });
      return true;
    }
    if (act === "mark") {
      this.markBlockRead(i);
      return true;
    }
    if (act === "markday") {
      const checks = Object.assign({}, s.checks);
      (s.day ? s.day.blocks : []).forEach(b => { checks[b.key] = true; });
      this.stopAudio();
      this.setSt({ checks: checks, view: s.prevView || "today" });
      this.paintDock();
      return true;
    }
    if (act === "resync") {
      const vid = el.getAttribute("data-verse");
      if (!vid || !this.audioEl) return true;
      const seg = (this.timecodes || []).find(x => x.vid === vid);
      if (seg) this.syncOffset = seg.a - (this.audioEl.currentTime + (this.LEAD || 0));
      this.setState({ activeVid: vid });
      return true;
    }
    if (act === "playpause") {
      this.unlockAudio();   // must happen in the click, before any await
      const a = s.audio;
      if (!a || a.state === "none") this.startAudio();
      else if (a.state === "playing") this.pauseAudio();
      else this.resumeAudio();
      return true;
    }
    if (act === "prevch") {
      this.unlockAudio();
      if (this.audioQueue && this.audioIdx > 0) this.playAt(this.audioIdx - 1);
      return true;
    }
    if (act === "nextch") {
      this.unlockAudio();
      const qn = (this.audioQueue || []).length;
      if (qn && this.audioIdx < qn - 1) this.playAt(this.audioIdx + 1);
      return true;
    }
    if (act === "jump") {
      this.unlockAudio();
      if (this.audioQueue && this.audioQueue.length) this.playAt(i);
      else this.startAudio().then(() => this.playAt(i));
      return true;
    }
    if (act === "speed") {
      const ladder = [1, 1.25, 1.5, 1.75, 2, 0.75];
      const at = ladder.indexOf(s.speed || 1);
      const next = ladder[(at + 1) % ladder.length];
      this.state.speed = next;
      this.applySpeed(next);
      this.queueSave();
      this.paintDock();
      return true;
    }
    if (act === "follow") {
      this.state.follow = !s.follow;
      this.queueSave();
      this.paintDock();
      if (this.state.follow && s.activeVid) this.paintActiveVerse();
      return true;
    }
    if (act === "queue") { this.setState({ queueOpen: !s.queueOpen }); return true; }
    if (act === "stopaudio") { this.stopAudio(); this.paintDock(); return true; }
    return false;
  }
});

  var saved = null;
  try {
    var raw = document.getElementById("dw-plan-data").textContent.trim();
    if (raw && raw !== "null") saved = JSON.parse(raw);
    if (saved && !saved.selections) saved = null;
  } catch (e) { saved = null; }

  var app = new DailyWord({ saved: saved });
  window.DW = app;
  console.log("Daily Word v3.5 ready", saved ? "plan loaded" : "no plan");
  app.start();
  window.addEventListener("beforeunload", function () { app.stopAudio(); });
})();
</script>
{% endraw %}

{% else %}

<div id="dw-root"><div class="dw-signin">
  <div class="dw-eyebrow dw-sage">A year in Scripture</div>
  <h1 class="dw-h1">Daily <i>Word</i></h1>
  <p class="dw-lede" style="margin-bottom:26px;">Sign in to build your reading plan and keep your progress.</p>
  <a class="dw-primary" href="/Login" style="display:inline-block;">Sign in</a>
</div></div>

{% endif %}
