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

  v4.8  The passage-foot button names its passage: 'Mark Genesis 1-2
        read' / '\u2713 Genesis 1-2 read'. After a long scroll the block
        head is far off-screen, so the button says what it marks. Centred
        below the text, larger tap target, forest outline that fills when
        done.
  v4.7  mark button moved to the passage foot
  v4.x/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-block-foot { display: flex; justify-content: center;
  margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-subtle); }
#dw-root .dw-mark-foot { font-size: 14px; padding: 12px 22px; min-height: 46px;
  border-color: var(--forest-200); color: var(--forest-600);
  text-align: center; }
#dw-root .dw-mark-foot:hover { background: var(--forest-50); }
#dw-root .dw-mark-foot.is-on { background: var(--forest-500);
  border-color: var(--forest-500); color: #fff; }
#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; }

/* ---------- calendar ---------- */
#dw-root .dw-cal-wall { display: grid;
  grid-template-columns: repeat(auto-fill, minmax(212px, 1fr)); gap: 34px 28px; }
#dw-root .dw-cal-label { 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); padding-bottom: 10px;
  border-bottom: 1px solid var(--border-subtle); margin-bottom: 12px; }
#dw-root .dw-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
#dw-root .dw-cal-pad { aspect-ratio: 1; }
#dw-root .dw-cal-day { aspect-ratio: 1; display: flex; align-items: center;
  justify-content: center; border-radius: 5px; font-size: 10.5px; padding: 0;
  min-width: 0; min-height: 0; box-sizing: border-box;
  transition: transform 120ms var(--ease); }
#dw-root .dw-cal-day:active { transform: scale(.94); }
#dw-root .dw-cal-day.is-full { background: var(--forest-500); color: #fff; }
#dw-root .dw-cal-day.is-part { background: var(--forest-200); color: var(--forest-700); }
#dw-root .dw-cal-day.is-future { background: var(--gray-50); color: var(--text-faint); }
#dw-root .dw-cal-day.is-past { background: var(--gray-100); color: var(--gray-600); }
#dw-root .dw-cal-day.is-today { outline: 1.5px solid var(--coral-500);
  outline-offset: 1px; font-weight: 700; }
#dw-root .dw-cal-legend { display: flex; gap: 20px; margin-top: 44px; padding-top: 18px;
  border-top: 1px solid var(--border-subtle); font-size: var(--text-caption);
  color: var(--text-muted); flex-wrap: wrap; }
#dw-root .dw-cal-legend span { display: inline-flex; align-items: center; gap: 7px; }
#dw-root .dw-swatch { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }
#dw-root .dw-swatch.is-full { background: var(--forest-500); }
#dw-root .dw-swatch.is-part { background: var(--forest-200); }
#dw-root .dw-swatch.is-past { background: var(--gray-100); }
#dw-root .dw-swatch.is-today { border: 1.5px solid var(--coral-500); box-sizing: border-box; }

/* ---------- full plan ---------- */
#dw-root .dw-wrap-plan { max-width: 760px; margin: 0 auto; }
#dw-root .dw-plan-head { display: flex; align-items: flex-end;
  justify-content: space-between; gap: 16px; margin-bottom: 34px; flex-wrap: wrap; }
#dw-root .dw-plan-row { display: flex; gap: 20px; padding: 12px 8px;
  border-bottom: 1px solid var(--border-subtle); font-size: var(--text-body-sm); }
#dw-root .dw-plan-row.is-today { background: var(--forest-50); border-radius: 8px; }
#dw-root .dw-plan-date { flex: none; width: 58px; color: var(--text-faint); }
#dw-root .dw-plan-readings { font-family: var(--font-display); font-size: 16px;
  color: var(--text-strong); }
#dw-root .dw-prt { display: none; }
@page { size: landscape; margin: 0.45in; }

/* Print sheet, built and paginated by the app (printPlan). Browser multicol
   printing duplicates and splits rows at fragment boundaries, so pagination
   is done in JS; this CSS only dresses the explicit pages. On screen the
   sheet is hidden; in print it is the ONLY thing shown - which also stops
   the site theme's header and footer printing their own pages. */
#dw-print { display: none; font-family: "Work Sans", -apple-system, sans-serif; }
@media print {
  body > *:not(#dw-print) { display: none !important; }
  #dw-print { display: block !important; position: static !important; }
  #dw-print .dwp-page { page-break-after: always; }
  #dw-print .dwp-page:last-child { page-break-after: auto; }
  #dw-print .dwp-title { font-family: "Newsreader", Georgia, serif; font-size: 15pt;
    color: #101C26; margin: 0 0 8pt; }
  #dw-print .dwp-title.dwp-cont { font-size: 11pt; color: #777; }
  #dw-print .dwp-cols { display: flex; gap: 0.3in; align-items: flex-start; }
  #dw-print .dwp-col { width: 2.9in; flex: none; }
  #dw-print .dwp-row { display: flex; gap: 6pt; padding: 1.5pt 2pt;
    border-bottom: 0.5pt solid #ccc; font-size: 10pt; line-height: 1.3;
    break-inside: avoid; page-break-inside: avoid; }
  #dw-print .dwp-date { flex: none; width: 42pt; color: #666; font-size: 10pt;
    white-space: nowrap; }
  #dw-print .dwp-ref { font-size: 10pt; color: #000; }
  #dw-print .dwp-row.is-today { background: #eef5f2; }
}
/* the measuring pass runs on screen at the same metrics print will use */
#dw-print .dwp-col { width: 278px; }
#dw-print .dwp-row { display: flex; gap: 8px; padding: 2px 3px; font-size: 13.3px;
  line-height: 1.3; border-bottom: 1px solid #ccc; }
#dw-print .dwp-date { flex: none; width: 56px; white-space: nowrap; }
</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: "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) => 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; this.loadedIdx = 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;
  }

  /* ---------- API cache (localStorage) ----------
     Bible text and edition discovery never change, yet they were re-fetched
     on every page load and every reader open - the bulk of api.bible usage.
     Chapter text caches for 30 days, discovery for 7. The audio resourceUrl
     is presigned and expiring, so it is deliberately NOT cached.
     SCHEMA is part of the key: bumping it abandons entries parsed by older
     code (e.g. anything cached before the v3.4 verse-1 repair). */
  cacheGet(key) {
    try {
      const raw = localStorage.getItem("dw2:" + key);
      if (!raw) return null;
      const box = JSON.parse(raw);
      if (!box || box.exp < Date.now()) return null;
      return box.d;
    } catch (e) { return null; }
  }

  cacheSet(key, data, days) {
    const box = JSON.stringify({ exp: Date.now() + days * 86400000, d: data });
    try { localStorage.setItem("dw2:" + key, box); }
    catch (e) {
      // quota: evict the oldest text entries and try once more
      try {
        const ks = [];
        for (let k = 0; k < localStorage.length; k++) {
          const n = localStorage.key(k);
          if (n && n.indexOf("dw2:text:") === 0) ks.push(n);
        }
        ks.slice(0, Math.max(20, ks.length >> 2)).forEach(n => localStorage.removeItem(n));
        localStorage.setItem("dw2:" + key, box);
      } catch (e2) { /* private mode or hard quota: run uncached */ }
    }
  }

  /* ---------- 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 === "calday") { this.setLocal({ view: "today", viewDay: i }); return; }
    if (act === "print") { this.printPlan(); 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"
      : view === "plan" ? "dw-wrap-plan" : "dw-wrap";
    const inner = view === "read" ? this.renderRead()
      : view === "today" ? this.renderToday()
      : view === "calendar" ? this.renderCalendar()
      : view === "plan" ? this.renderPlan()
      : 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"], ["calendar", "Calendar"], ["plan", "Full plan"], ["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>";
  }

  /* ---------- calendar (ported from the v5 prototype) ---------- */

  renderCalendar() {
    const s = this.state;
    const todayIdx = this.todayIndex(s.startDate);
    const MON3 = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
    const months = [];
    let cur = null;
    for (let i = 0; i < 365; i++) {
      const d = this.dateOf(i);
      const label = ["January","February","March","April","May","June","July",
        "August","September","October","November","December"][d.getMonth()] + " " + d.getFullYear();
      if (!cur || cur.label !== label) {
        cur = { label: label, cells: [] };
        months.push(cur);
        for (let p = 0; p < d.getDay(); p++) cur.cells.push(null);
      }
      let sched = 0, chk = 0;
      s.selections.forEach((sel, si) => {
        if (this.dayChapters(sel, i).length) { sched++; if (s.checks["d" + i + "s" + si]) chk++; }
      });
      cur.cells.push({
        i: i, n: d.getDate(),
        title: MON3[d.getMonth()] + " " + d.getDate() + " \u2014 day " + (i + 1),
        cls: (sched && chk === sched) ? "is-full" : (chk > 0) ? "is-part"
          : (i > todayIdx) ? "is-future" : "is-past",
        today: i === todayIdx
      });
    }
    const grid = months.map(m =>
      '<div class="dw-cal-month"><div class="dw-cal-label">' + this.esc(m.label) + "</div>"
      + '<div class="dw-cal-grid">'
      + m.cells.map(c => c === null ? '<div class="dw-cal-pad"></div>'
        : '<button type="button" class="dw-cal-day ' + c.cls + (c.today ? " is-today" : "")
          + '" data-act="calday" data-i="' + c.i + '" title="' + this.esc(c.title) + '">'
          + c.n + "</button>").join("")
      + "</div></div>").join("");
    return '<section class="dw-hero" style="margin-bottom:44px;">'
      + '<div class="dw-eyebrow dw-sage">Your year</div>'
      + '<h1 class="dw-h1" style="margin-bottom:0;">365 days in the <i>Word</i></h1>'
      + "</section>"
      + '<div class="dw-cal-wall">' + grid + "</div>"
      + '<div class="dw-cal-legend">'
      + '<span><i class="dw-swatch is-full"></i> All read</span>'
      + '<span><i class="dw-swatch is-part"></i> Partly read</span>'
      + '<span><i class="dw-swatch is-past"></i> Not yet</span>'
      + '<span><i class="dw-swatch is-today"></i> Today</span>'
      + "</div>";
  }

  /* ---------- full plan (ported from the v5 prototype) ---------- */

  renderPlan() {
    const s = this.state;
    const todayIdx = this.todayIndex(s.startDate);
    const MON3 = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
    const rows = [];
    for (let i = 0; i < 365; i++) {
      const d = this.dateOf(i);
      const dayChs = s.selections.map(sel => this.dayChapters(sel, i));
      const parts = dayChs.map(chs => this.formatChapters(chs)).filter(Boolean);
      const compact = dayChs.map(chs => this.formatChapters(chs, true)).filter(Boolean);
      rows.push('<div class="dw-plan-row' + (i === todayIdx ? " is-today" : "") + '">'
        + '<span class="dw-plan-date">' + MON3[d.getMonth()] + " " + d.getDate() + "</span>"
        + '<span class="dw-plan-readings dw-scr">' + this.esc(parts.join("  \u00b7  ") || "\u2014") + "</span>"
        + '<span class="dw-plan-readings dw-prt">' + this.esc(compact.join(" \u00b7 ") || "\u2014") + "</span>"
        + "</div>");
    }
    return '<div class="dw-plan-head">'
      + '<div><div class="dw-eyebrow dw-sage">Full plan</div>'
      + '<h1 class="dw-h1" style="margin-bottom:0;">Your reading plan</h1></div>'
      + '<button type="button" class="dw-readpill" data-act="print">Print</button>'
      + "</div>"
      + '<div class="dw-plan-list">' + rows.join("") + "</div>";
  }

  /* ---------- print (self-paginated; browser multicol printing duplicates
       and splits rows at fragment boundaries, so the app measures and packs
       rows into explicit columns and pages itself) ---------- */

  // Pure packer: given row heights, a column height and columns per page,
  // return pages -> columns -> row indices. Order preserved, nothing lost.
  packPrintRows(heights, colH, colsPerPage) {
    const pages = [];
    let page = [], col = [], used = 0;
    const closeCol = () => {
      if (col.length) page.push(col);
      col = []; used = 0;
      if (page.length === colsPerPage) { pages.push(page); page = []; }
    };
    heights.forEach((h, i) => {
      if (used > 0 && used + h > colH) closeCol();
      col.push(i);
      used += h;
    });
    if (col.length) page.push(col);
    if (page.length) pages.push(page);
    return pages;
  }

  printPlan() {
    try {
      const s = this.state;
      const todayIdx = this.todayIndex(s.startDate);
      const MON3 = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
      const rows = [];
      for (let i = 0; i < 365; i++) {
        const d = this.dateOf(i);
        const compact = s.selections
          .map(sel => this.formatChapters(this.dayChapters(sel, i), true))
          .filter(Boolean)
          .map(p => p.replace(/ /g, "\u00a0"));
        rows.push({
          date: MON3[d.getMonth()] + "\u00a0" + d.getDate(),
          refs: compact.join(" \u00b7 ") || "\u2014",
          today: i === todayIdx
        });
      }
      const old = document.getElementById("dw-print");
      if (old && old.parentNode) old.parentNode.removeChild(old);
      const sheet = document.createElement("div");
      sheet.id = "dw-print";
      // measure on screen, far off-viewport; layout is computed there
      sheet.style.display = "block";
      sheet.style.position = "fixed";
      sheet.style.left = "-10000px";
      sheet.style.top = "0";
      document.body.appendChild(sheet);
      const rowHtml = (r) => '<div class="dwp-row' + (r.today ? " is-today" : "") + '">'
        + '<span class="dwp-date">' + this.esc(r.date) + "</span>"
        + '<span class="dwp-ref">' + this.esc(r.refs) + "</span></div>";
      // measuring column at exact print width
      const meas = document.createElement("div");
      meas.className = "dwp-col";
      meas.innerHTML = rows.map(rowHtml).join("");
      sheet.appendChild(meas);
      const kids = meas.children;
      let heights = [];
      for (let k = 0; k < kids.length; k++) heights.push(kids[k].offsetHeight || 18);
      // if measurement is unavailable, assume single-line rows rather than
      // producing an empty sheet
      if (heights.length !== rows.length) heights = rows.map(() => 22);
      // letter landscape at 0.45in margins: ~10.1in x 7.6in usable.
      // 3 columns of 2.9in + two 0.3in gaps = 9.5in. Column height is packed
      // with deliberate slack (~0.5in) below the theoretical fit: measured
      // screen heights can run a line short of print rendering, and packing
      // to the wire tears the last row of a column across the page boundary
      // every few pages. Slack costs at most one extra page and buys
      // certainty.
      const pages = this.packPrintRows(heights, Math.round(6.5 * 96), 3);
      sheet.removeChild(meas);
      sheet.innerHTML = pages.map((page, pi) =>
        '<div class="dwp-page">'
        + (pi === 0 ? '<div class="dwp-title">Daily Word \u2014 your reading plan</div>' : '<div class="dwp-title dwp-cont">Daily Word (continued)</div>')
        + '<div class="dwp-cols">'
        + page.map(col => '<div class="dwp-col">' + col.map(i => rowHtml(rows[i])).join("") + "</div>").join("")
        + "</div></div>").join("");
      // hand back to the stylesheet: hidden on screen, shown in print
      sheet.style.display = "none";
      sheet.style.position = "";
      sheet.style.left = "";
      sheet.style.top = "";
      const cleanup = () => {
        const el = document.getElementById("dw-print");
        if (el && el.parentNode) el.parentNode.removeChild(el);
        window.removeEventListener("afterprint", cleanup);
      };
      window.addEventListener("afterprint", cleanup);
      window.print();
    } catch (e) {
      console.warn("Daily Word print:", e && e.message);
      window.print();
    }
  }

  /* ---------- 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, ";
      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 hit = this.cacheGet("text:" + this.TEXT_BIBLE + ":" + cid);
          if (hit) {
            this.chapterVerses[cid] = { vids: hit.vids, lens: hit.lens };
            b.paras = b.paras.concat(hit.paras);
            copyright = hit.copyright || copyright;
            continue;
          }
          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;
          this.cacheSet("text:" + this.TEXT_BIBLE + ":" + cid,
            { paras: parsed, vids: vids, lens: lens, copyright: j.data.copyright || "" }, 30);
        }
      }
      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;
    const hit = this.cacheGet("audio-discovery");
    if (hit) {
      this.audioBibleId = hit.id;
      this.audioBibleName = hit.name;
      this.audioBibleFull = hit.full;
      this.audioBibleCopyright = hit.copyright;
      this.audioChapters = new Set(hit.chapters);
      this.audioReady = true;
      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;
        this.cacheSet("audio-discovery", {
          id: this.audioBibleId, name: this.audioBibleName,
          full: this.audioBibleFull, copyright: this.audioBibleCopyright,
          chapters: Array.from(set)
        }, 7);
      }
    } 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.loadedIdx = i;
      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>"
          + "</div>" + paras
          + '<div class="dw-block-foot">'
          + '<button type="button" class="dw-mark dw-mark-foot' + (done ? " is-on" : "") + '" data-act="mark" data-i="'
          + bi + '">' + (done ? "\u2713 " + this.esc(b.ref) + " read"
                              : "Mark " + this.esc(b.ref) + " read") + "</button>"
          + "</div></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 === "cued" ? "Chapter " + (a.i + 1) + " of " + (a.n || q.length)
        + " \u00b7 press play"
      : 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" && s.audio.state !== "none";
    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="dismiss" title="Dismiss the player">\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;
    }
  },

  scrollToChapter(entry) {
    requestAnimationFrame(() => {
      const vsel = '[data-verse="' + entry.cid + '.1"]';
      if (this.root.querySelector(vsel)) this.scrollToEl(vsel, 0.12);
      else this.scrollToEl('[data-block="' + entry.bi + '"]', 0.12);
    });
  },

  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 === "playing") { this.pauseAudio(); return true; }
      if (a && a.state === "paused" && this.loadedIdx === this.audioIdx) {
        this.resumeAudio(); return true;
      }
      if (!a) { this.startAudio(); return true; }
      // cued (pointer moved while paused/idle), done, or error:
      // load the pointed chapter now - this is the moment the API call happens
      const q = (this.audioQueue && this.audioQueue.length) ? this.audioQueue : this.dayAudioQueue();
      if (!q.length) { this.startAudio(); return true; }
      this.audioQueue = q;
      this.playAt(this.audioIdx || 0);
      return true;
    }
    if (act === "prevch" || act === "nextch") {
      this.unlockAudio();
      const q = (this.audioQueue && this.audioQueue.length) ? this.audioQueue : this.dayAudioQueue();
      if (!q.length) return true;
      this.audioQueue = q;
      const cur = this.audioIdx || 0;
      const target = act === "prevch" ? Math.max(0, cur - 1) : Math.min(q.length - 1, cur + 1);
      if (target === cur) return true;
      if (s.audio && s.audio.state === "playing") {
        this.playAt(target);          // listening: skip loads and keeps playing
      } else {
        // paused or idle: just CUE it. No fetch, no autoplay, no API call -
        // play loads whatever is cued.
        this.audioIdx = target;
        this.setState({ audio: { state: "cued", i: target, n: q.length } });
      }
      return true;
    }
    if (act === "jump") {
      this.unlockAudio();
      const q = (this.audioQueue && this.audioQueue.length) ? this.audioQueue : this.dayAudioQueue();
      if (!q.length || !q[i]) return true;
      this.audioQueue = q;
      const entry = q[i];
      if (s.audio && s.audio.state === "playing") {
        this.playAt(i);
        this.setState({ queueOpen: false });
      } else {
        this.audioIdx = i;
        this.setState({ audio: { state: "cued", i: i, n: q.length }, queueOpen: false });
      }
      // Choosing a chapter is a navigation: close the sheet and bring the
      // reader to that chapter's opening, so play reads what you're seeing.
      this.scrollToChapter(entry);
      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 === "dismiss") {
      this.stopAudio();                    // pauses, releases, clears state
      this.setState({ queueOpen: false }); // sheet goes with it
      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 v4.8 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 %}
