123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Storyboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
@font-face {
  font-family: 'Familjen Grotesk';
  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');
  font-weight: 700; font-display: swap;
}
@font-face {
  font-family: 'Familjen Grotesk';
  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2');
  font-weight: 600; font-display: swap;
}
@font-face {
  font-family: 'Familjen Grotesk';
  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');
  font-weight: 500; font-display: swap;
}
@font-face {
  font-family: 'Satoshi';
  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');
  font-weight: 400; font-display: swap;
}
@font-face {
  font-family: 'Satoshi';
  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');
  font-weight: 500; font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2');
  font-weight: 400; font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-500-normal.woff2') format('woff2');
  font-weight: 500; font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-600-normal.woff2') format('woff2');
  font-weight: 600; font-display: swap;
}
  :root {
    --bg: #0d1117;
    --surface: #161b22;
    --elevated: #1c2128;
    --border: #30363d;
    --border-muted: #21262d;
    --text: #e6edf3;
    --text2: #7d8590;
    --text3: #484f58;
    --accent: #00b8b0;
    --accent-bright: #00e6dc;
    --accent-glow: rgba(0,184,176,0.25);
    --purple: #8C6BF7;
    --purple-light: #a890ff;
    --green: #4aa857;
    --red: #f85149;
    --yellow: #d4aa3e;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font-family: 'Familjen Grotesk', 'Satoshi', -apple-system, "Segoe UI", sans-serif;
    font-size: 14px;
    line-height: 1.5;
  }

  /* Canonical Adom app-header โ€” see gallia/skills/adom-app-header/SKILL.md.
     Single row, 44 px tall: brand | subject (title + state-pill + meta) |
     hover popovers for app info + subject info. Matches adom-step etc. */
  header {
    height: 44px;
    padding: 0 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    border-bottom: 1px solid var(--border-muted);
    background: var(--surface);
    position: relative;
    font-family: 'Familjen Grotesk', sans-serif;
    flex-shrink: 0;
  }
  header .brand {
    display: flex;
    align-items: center;
    gap: 8px;
    color: var(--accent);
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.01em;
    cursor: default;
    flex-shrink: 0;
    outline: none;
  }
  header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }
  header .brand:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
  header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }
  header .brand .app-name { color: var(--accent); }
  header .brand .app-subtitle { color: var(--text2); font-weight: 500; }
  header .brand .sep { color: var(--text3); font-weight: 400; }
  header .vsep {
    width: 1px;
    height: 18px;
    background: var(--border);
    flex-shrink: 0;
  }
  header .subject {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    flex: 1 1 auto;
    cursor: default;
  }
  header .subject-name {
    color: var(--text);
    font-family: 'Familjen Grotesk', sans-serif;
    font-size: 14px;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
  }
  header .subject-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    color: var(--text2);
    font-family: 'JetBrains Mono', monospace;
    font-size: 11px;
    white-space: nowrap;
    flex-shrink: 0;
  }
  header .subject-meta .sep { color: var(--text3); }
  @media (max-width: 720px) {
    header .subject-meta { display: none; }
  }
  header .state-pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 2px 8px;
    border-radius: 10px;
    font-family: 'Familjen Grotesk', sans-serif;
    font-size: 10px;
    font-weight: 500;
    letter-spacing: 0.3px;
    text-transform: none;
    background: var(--elevated);
    border: 1px solid var(--border);
    color: var(--text2);
    flex-shrink: 0;
  }
  header .state-pill::before {
    content: '';
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--text3);
  }
  header .state-pill.live::before { background: var(--red); animation: livepulse 1.6s ease-in-out infinite; }
  header .state-pill.live { color: var(--red); border-color: var(--red); }
  header .state-pill.review::before { background: var(--accent); }
  header .state-pill.review { color: var(--accent); border-color: var(--accent-glow); }
  header .state-pill.preview::before { background: var(--purple); }
  header .state-pill.preview { color: var(--purple); border-color: var(--purple); }
  @keyframes livepulse {
    0%,100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); }
    50%     { box-shadow: 0 0 0 4px rgba(248,81,73,0); }
  }

  /* Interactive hover-popover */
  header .hover-popover {
    position: absolute;
    top: calc(100% + 6px);
    left: 16px;
    background: var(--elevated);
    border: 1px solid var(--border);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.5);
    padding: 12px 14px;
    min-width: 300px;
    max-width: 420px;
    font-family: 'Satoshi', sans-serif;
    font-size: 12px;
    color: var(--text);
    z-index: 99999;
    opacity: 0;
    transform: translateY(-2px);
    pointer-events: none;
    transition: opacity 120ms ease, transform 120ms ease;
  }
  header .hover-popover.show {
    opacity: 1;
    transform: none;
    pointer-events: auto;
  }
  header .hover-popover.compact { min-width: 220px; left: auto; right: 16px; }
  header .hover-popover .popover-title {
    font-family: 'Familjen Grotesk', sans-serif;
    font-size: 14px;
    font-weight: 700;
    color: var(--text);
    margin-bottom: 8px;
  }
  header .hover-popover .popover-row {
    display: flex;
    gap: 12px;
    padding: 3px 0;
    align-items: baseline;
  }
  header .hover-popover .popover-row .k {
    color: var(--text3);
    font-family: 'JetBrains Mono', monospace;
    font-size: 10px;
    text-transform: lowercase;
    width: 64px;
    flex-shrink: 0;
  }
  header .hover-popover .popover-row .v {
    color: var(--text);
    font-family: 'Satoshi', sans-serif;
    font-size: 12px;
    flex: 1;
    word-break: break-word;
  }
  header .hover-popover .popover-actions {
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px solid var(--border-muted);
    display: flex;
    gap: 12px;
  }
  header .hover-popover .popover-link {
    color: var(--accent);
    text-decoration: none;
    font-family: 'Familjen Grotesk', sans-serif;
    font-size: 12px;
    font-weight: 600;
  }
  header .hover-popover .popover-link:hover { text-decoration: underline; }

  /* Timeline strip โ€” scrolls with the rest of the page. */
  .timeline {
    display: flex;
    align-items: center;
    gap: 6px;
    background: var(--elevated);
    border: 1px solid var(--border);
    padding: 14px 18px;
    margin: 18px 32px;
    border-radius: 8px;
    overflow-x: auto;
    white-space: nowrap;
  }
  .tl-step {
    flex-shrink: 0;
    padding: 7px 13px;
    border-radius: 6px;
    background: var(--surface);
    border: 1px solid var(--border);
    font-size: 12px;
    color: var(--text);
    cursor: pointer;
    text-decoration: none;
    display: inline-flex;
    flex-direction: column;
    gap: 2px;
  }
  .tl-step.speedup { border-color: var(--purple); background: #1f1530; }
  .tl-step:hover { border-color: var(--accent-bright); }
  .tl-step .num { color: var(--text3); font-size: 10px; font-weight: 600; }
  .tl-step .mark { color: var(--purple); font-size: 10px; }

  .finals {
    margin: 0 32px 18px;
    display: flex;
    gap: 16px;
    flex-wrap: wrap;
  }
  .finals .card {
    flex: 1;
    min-width: 320px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 16px;
  }
  .finals h3 {
    margin: 0 0 8px;
    font-size: 12px;
    text-transform: uppercase;
    letter-spacing: 0.8px;
    color: var(--text3);
  }

  .steps { padding: 0 32px 80px; }
  .step-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 24px;
    margin-top: 20px;
  }
  .step-card h2 {
    margin: 0 0 6px;
    font-size: 22px;
    color: var(--accent-bright);
    display: flex;
    align-items: center;
    gap: 12px;
  }
  .step-card h2 .badge {
    background: var(--accent);
    color: var(--bg);
    padding: 4px 10px;
    border-radius: 16px;
    font-size: 13px;
    font-weight: 700;
  }
  .step-card .desc { color: var(--text2); font-size: 13px; margin-bottom: 14px; }

  .speedup-pill {
    display: inline-block;
    padding: 4px 10px;
    border-radius: 12px;
    background: var(--purple);
    color: #fff;
    font-size: 12px;
    font-weight: 600;
    margin-left: 8px;
  }
  /* "Just updated" affordance. Sentence case (per human-ui-patterns
     ยง1e โ€” never ALL CAPS for status pills). No emoji glyph (the ๐Ÿ†•
     character renders as a colored "NEW" badge in most fonts and
     would double up with the label). Tooltip on the pill explains
     what "Just updated" means + when it changed (per ยง1a). */
  .new-pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px;
    border-radius: 12px;
    background: var(--accent-bright);
    color: var(--bg);
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.02em;
    text-transform: none;
    margin-left: 8px;
    cursor: help;
  }
  .new-pill .new-pill-dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--bg);
    animation: new-pulse 1.6s ease-in-out infinite;
  }
  .new-pill .new-pill-when {
    color: rgba(13,17,23,0.65);
    font-weight: 500;
    font-family: 'JetBrains Mono', monospace;
    font-size: 11px;
  }
  .step-card.is-new {
    border-color: var(--accent-bright);
    box-shadow: 0 0 0 1px var(--accent-glow), 0 0 20px var(--accent-glow);
  }
  .stat-new { color: var(--accent-bright); font-weight: 600; }
  @keyframes new-pulse {
    0%,100% { transform: scale(1); opacity: 0.5; }
    50%     { transform: scale(1.4); opacity: 1; }
  }

  .actions-list {
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 12px 16px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    line-height: 1.7;
    color: var(--text2);
    margin-top: 12px;
    max-height: 160px;
    overflow-y: auto;
  }
  .actions-list .muted { color: var(--text3); }

  .warnings {
    background: #2d2412;
    border: 1px solid #a8791e;
    color: #f0c260;
    border-radius: 6px;
    padding: 10px 14px;
    font-size: 12px;
    margin-top: 12px;
  }

  .clip-row { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 18px; }
  .clip-block { display: flex; flex-direction: column; gap: 6px; }
  .clip-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 1px;
    font-weight: 600;
  }
  .clip-label.raw { color: var(--accent-bright); }
  .clip-label.fast { color: var(--purple); }
  .clip-label-hint {
    color: var(--text3);
    font-weight: 400;
    text-transform: none;
    letter-spacing: 0.2px;
    font-size: 10px;
    margin-left: 6px;
  }
  /* Collapsible TTS narration disclosure โ€” folded by default. */
  .narration-fold {
    margin-top: 10px;
    border: 1px solid var(--border-muted);
    border-radius: 6px;
    background: var(--elevated);
  }
  .narration-fold summary {
    cursor: pointer;
    padding: 8px 12px;
    color: var(--text2);
    font-size: 12px;
    font-weight: 500;
    user-select: none;
    list-style: none;
  }
  .narration-fold summary::-webkit-details-marker { display: none; }
  .narration-fold summary:hover { color: var(--text); }
  .narration-fold[open] summary { color: var(--accent); border-bottom: 1px solid var(--border-muted); }
  .narration-fold .narration-body {
    padding: 10px 14px 12px;
    color: var(--text);
    font-size: 13px;
    line-height: 1.55;
    font-family: 'Familjen Grotesk', sans-serif;
    white-space: pre-wrap;
  }
  .clip-url {
    color: var(--text3);
    font-family: 'JetBrains Mono', monospace;
    font-size: 11px;
    margin-top: 4px;
  }

  .video-wrap {
    position: relative;
    aspect-ratio: 16 / 10;
    background: #000;
    border-radius: 6px;
    border: 1px solid var(--border);
    overflow: hidden;
  }
  .video-wrap video {
    width: 100%;
    height: 100%;
    display: block;
    background: #000;
    object-fit: contain;
  }

  /* Lightbox for image_path + console_png_path */
  .step-card .thumb {
    cursor: zoom-in;
    transition: opacity 0.15s;
    max-width: 100%;
    border-radius: 6px;
    border: 1px solid var(--border);
  }
  .step-card .thumb:hover { opacity: 0.85; }
  .lightbox {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 1000;
    background: rgba(0,0,0,0.92);
    align-items: center;
    justify-content: center;
    padding: 40px;
    cursor: zoom-out;
  }
  .lightbox.open { display: flex; }
  .lightbox img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    box-shadow: 0 8px 32px rgba(0,0,0,0.6);
    border: 1px solid var(--border);
  }
  .lightbox .close {
    position: absolute;
    top: 20px;
    right: 32px;
    color: var(--text);
    font-size: 32px;
    cursor: pointer;
    background: none;
    border: none;
    padding: 0;
  }
  .lightbox .caption {
    position: absolute;
    bottom: 20px;
    left: 0;
    right: 0;
    color: var(--text2);
    text-align: center;
    font-size: 13px;
    font-family: 'JetBrains Mono', monospace;
  }

  /* Fullscreen video overlay โ€” works inside iframes that block the
     native Fullscreen API because it's pure CSS positioning. */
  .video-fullscreen {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 2000;
    background: #000;
    align-items: center;
    justify-content: center;
  }
  .video-fullscreen.open { display: flex; }
  .video-fullscreen video {
    max-width: 100%;
    max-height: 100%;
    width: auto;
    height: auto;
    background: #000;
  }
  .video-fullscreen .close {
    position: absolute;
    top: 16px;
    right: 20px;
    z-index: 2001;
    background: rgba(0,0,0,0.85);
    border: 1px solid rgba(255,255,255,0.3);
    color: #fff;
    font-size: 20px;
    cursor: pointer;
    padding: 8px 14px;
    border-radius: 4px;
    font-family: inherit;
  }
  .video-fullscreen .close:hover { border-color: var(--accent-bright); color: var(--accent-bright); }
  .video-fullscreen .label {
    position: absolute;
    top: 16px;
    left: 20px;
    z-index: 2001;
    color: var(--text2);
    font-size: 13px;
    font-family: 'JetBrains Mono', monospace;
    background: rgba(0,0,0,0.85);
    padding: 8px 12px;
    border-radius: 4px;
  }

  .empty-state {
    margin: 60px 32px;
    padding: 40px;
    background: var(--elevated);
    border: 1px dashed var(--border);
    border-radius: 10px;
    text-align: center;
    color: var(--text3);
    font-family: 'JetBrains Mono', monospace;
  }
  .empty-state strong { color: var(--text); }
</style>
</head>
<body>
<header>
  <div class="brand" data-tooltip-target="app-info" tabindex="0">
    <svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2v2h2V6H5zm4 0v12h6V6H9zm8 0v2h2V6h-2zM5 10v2h2v-2H5zm12 0v2h2v-2h-2zM5 14v2h2v-2H5zm12 0v2h2v-2h-2zM5 18v0h2v0H5zm12 0v0h2v0h-2zM11 8v8h2V8h-2z"/>
    </svg>
    <span class="app-name">adom-video-post</span>
    <span class="sep">ยท</span>
    <span class="app-subtitle">storyboard</span>
  </div>

  <div class="vsep" aria-hidden="true"></div>

  <div class="subject" data-tooltip-target="subject-info">
    <span class="subject-name" id="title">Storyboard</span>
    <span id="statePill" class="state-pill">Loading</span>
    <span class="subject-meta" id="stats"></span>
  </div>

  <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">
    <div class="popover-title">adom-video-post</div>
    <div class="popover-row"><span class="k">about</span>
      <span class="v">Per-clip review, voiceover narration, and final concat for Adom feature-tour demos.</span></div>
    <div class="popover-row"><span class="k">screen</span><span class="v">storyboard ยท per-clip review</span></div>
    <div class="popover-row"><span class="k">version</span><span class="v">v0.3.0</span></div>
    <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc ยท adom.inc</span></div>
    <div class="popover-row"><span class="k">tts</span>
      <span class="v"><a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener" data-tooltip="Open the adom-tts wiki page โ€” shared edge-tts wrapper with pronunciation overrides. Run `adom-tts` to launch the playground locally.">adom-tts โ†—</a> โ€” house TTS engine + pronunciation overrides shared by every Adom tool that speaks. Mispronounced terms? Add them once there.</span></div>
    <div class="popover-actions">
      <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-video-post" target="_blank" rel="noopener">Open wiki โ†—</a>
      <a class="popover-link" href="https://github.com/adom-inc/adom-video-post" target="_blank" rel="noopener">GitHub โ†—</a>
    </div>
  </div>

  <div class="hover-popover compact" id="subject-info" role="dialog">
    <div class="popover-row"><span class="k">manifest</span><span class="v" id="tipManifest">โ€”</span></div>
    <div class="popover-row"><span class="k">clips</span><span class="v" id="tipClips">โ€”</span></div>
    <div class="popover-row"><span class="k">duration</span><span class="v" id="tipDuration">โ€”</span></div>
  </div>
</header>

<script>
// SHOW_DELAY = 500 ms per human-ui-patterns ยง1b; HIDE_GRACE = 220 ms.
(function attachHeaderPopovers(){
  const SHOW_DELAY = 500;
  const HIDE_GRACE = 220;
  const wire = () => {
    document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {
      if (trigger.__popoverWired) return;
      trigger.__popoverWired = true;
      const target = document.getElementById(trigger.dataset.tooltipTarget);
      if (!target) return;
      let showTimer = null, hideTimer = null;
      const show = () => {
        clearTimeout(hideTimer);
        if (target.classList.contains('show')) return;
        clearTimeout(showTimer);
        showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);
      };
      const queueHide = () => {
        clearTimeout(showTimer);
        clearTimeout(hideTimer);
        hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);
      };
      trigger.addEventListener('mouseenter', show);
      trigger.addEventListener('mouseleave', queueHide);
      trigger.addEventListener('focus', show);
      trigger.addEventListener('blur', queueHide);
      target.addEventListener('mouseenter', show);
      target.addEventListener('mouseleave', queueHide);
      trigger.addEventListener('click', e => {
        clearTimeout(showTimer);
        if (target.classList.contains('show')) target.classList.remove('show');
        else target.classList.add('show');
        e.stopPropagation();
      });
    });
    document.addEventListener('click', e => {
      document.querySelectorAll('header .hover-popover.show').forEach(p => {
        if (!p.contains(e.target)) {
          const trigger = document.querySelector(`[data-tooltip-target="${p.id}"]`);
          if (!trigger || !trigger.contains(e.target)) p.classList.remove('show');
        }
      });
    }, true);
  };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
  else wire();
})();
</script>

<div id="finals" class="finals" style="display:none;"></div>
<div id="timeline" class="timeline" style="display:none;"></div>
<div id="steps" class="steps"></div>

<!-- Lightbox for click-to-zoom on screenshot thumbs -->
<div id="lightbox" class="lightbox" onclick="closeLightbox(event)">
  <button class="close" onclick="closeLightbox()">&times;</button>
  <img id="lightbox-img" alt="">
  <div class="caption" id="lightbox-caption"></div>
</div>

<!-- Fullscreen video overlay -->
<div id="video-fs" class="video-fullscreen" onclick="closeVideoFs(event)">
  <button class="close" onclick="closeVideoFs()">&times; Close</button>
  <div class="label" id="video-fs-label"></div>
  <video id="video-fs-player" controls autoplay></video>
</div>

<!-- EVAL_CLIENT -->

<script>
// Expose appCtx early so /eval snippets can inspect even before
// the first /manifest fetch resolves.
window.__appCtx = window.__appCtx || {};
window.__appCtx.subcommand = 'storyboard';
window.__appCtx.manifest = null;
window.__appCtx.version = 0;

// --- console forwarding (same pattern as voiceover/ui.html) ---
(function() {
  const orig = { log: console.log, warn: console.warn, error: console.error };
  function forward(level, args) {
    orig[level].apply(console, args);
    try {
      fetch('console', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          level: level,
          message: args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '),
          ts: Date.now(),
        }),
      }).catch(() => {});
    } catch (_) {}
  }
  console.log = function() { forward('log', arguments); };
  console.warn = function() { forward('warn', arguments); };
  console.error = function() { forward('error', arguments); };
})();

function esc(s) {
  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// --- lightbox / fullscreen helpers ---
window.closeLightbox = function(e) {
  if (e && e.target && e.target.tagName === 'IMG') return;
  document.getElementById('lightbox').classList.remove('open');
};
// expandVideo() removed: the iframe now has fullscreen permissions
// granted (Adom Hydrogen + pup proxy) so the native <video> element's
// fullscreen control handles enlargement directly. The custom Expand
// button + overlay player are no longer needed.
window.closeVideoFs = function(e) {
  if (e && e.target && e.target.tagName === 'VIDEO') return;
  const fs = document.getElementById('video-fs');
  const player = document.getElementById('video-fs-player');
  player.pause();
  player.src = '';
  fs.classList.remove('open');
};
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') { closeLightbox(); closeVideoFs(); }
});

// --- render the manifest ---
let liveTimer = null;

// "Just updated" tracking. Each manifest+clip gets its own localStorage
// entry recording the highest mtime ever observed. When fetchManifest
// returns a clip whose mtime > the stored last-seen, mark it as
// recently updated. After the user has had ~30 s to look at the page,
// bump last-seen so it's no longer flagged on the next reload.
//
// IMPORTANT: on the very first visit to a manifest (no localStorage
// keys yet) we record current mtimes silently so NOTHING is flagged.
// "Everything is new" is a useless state โ€” the user wants to know
// what changed *between* visits, not what existed on first load.
function newSinceLastSeenKey(manifest, clip, kind) {
  const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');
  return `vp:lastseen:${slug}:${clip.id}:${kind}`;
}
function manifestEverSeenKey(manifest) {
  const slug = (manifest.title || 'storyboard').toLowerCase().replace(/[^a-z0-9]+/g, '-');
  return `vp:everseen:${slug}`;
}
function isFirstVisit(manifest) {
  return !localStorage.getItem(manifestEverSeenKey(manifest));
}
function isNewSinceLastSeen(manifest, clip) {
  if (isFirstVisit(manifest)) return false;
  const rawMtime = clip.raw_mtime_ms || 0;
  if (!rawMtime) return false;
  const seen = parseInt(localStorage.getItem(newSinceLastSeenKey(manifest, clip, 'raw')) || '0', 10);
  return rawMtime > seen;
}
function markClipSeen(manifest, clip) {
  if (clip.raw_mtime_ms) {
    localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'raw'), String(clip.raw_mtime_ms));
  }
  if (clip.fast_mtime_ms) {
    localStorage.setItem(newSinceLastSeenKey(manifest, clip, 'fast'), String(clip.fast_mtime_ms));
  }
}
function markManifestEverSeen(manifest) {
  localStorage.setItem(manifestEverSeenKey(manifest), String(Date.now()));
}
// Friendly relative-time string for "this clip was updated <when>".
function relativeUpdated(mtimeMs) {
  if (!mtimeMs) return '';
  const ageMs = Date.now() - mtimeMs;
  const sec = Math.round(ageMs / 1000);
  if (sec < 60) return `${sec}s ago`;
  const min = Math.round(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.round(min / 60);
  if (hr < 24) return `${hr}h ago`;
  return `${Math.round(hr / 24)}d ago`;
}

function renderManifest(manifest, version) {
  document.title = manifest.title || 'Storyboard';
  document.getElementById('title').textContent = manifest.title || 'Storyboard';

  const clipCount = manifest.clips.length;
  const totalSpeedups = manifest.clips.reduce((n, c) => n + (c.speedup_events?.length || 0), 0);
  const withFast = manifest.clips.filter(c => c.fast_path).length;
  const newCount = manifest.clips.filter(c => isNewSinceLastSeen(manifest, c)).length;

  // Stats row (subject-meta) + tooltip mirrors
  const parts = [`${clipCount} clip${clipCount === 1 ? '' : 's'}`];
  if (totalSpeedups > 0) parts.push(`${totalSpeedups} speedup${totalSpeedups === 1 ? '' : 's'}`);
  if (withFast > 0) parts.push(`${withFast} sped-up`);
  if (newCount > 0) parts.push(`<span class="stat-new">${newCount} new</span>`);
  document.getElementById('stats').innerHTML = parts
    .map(p => `<span>${p}</span>`)
    .join('<span class="sep">ยท</span>');
  const tipClips = document.getElementById('tipClips');
  const tipManifest = document.getElementById('tipManifest');
  if (tipClips) tipClips.textContent = `${clipCount} (${withFast} sped-up)`;
  if (tipManifest && manifest.title) tipManifest.textContent = manifest.title;

  // State pill โ€” preview if no fast clips yet, review if all present,
  // live if the last manifest mtime change was recent.
  const pill = document.getElementById('statePill');
  if (window.__appCtx.lastManifestChange && (Date.now() - window.__appCtx.lastManifestChange) < 5000) {
    pill.className = 'state-pill live';
    pill.textContent = 'Live';
  } else if (withFast === clipCount && clipCount > 0) {
    pill.className = 'state-pill review';
    pill.textContent = 'Review';
  } else {
    pill.className = 'state-pill preview';
    pill.textContent = 'Preview';
  }

  // Finals
  const finalsEl = document.getElementById('finals');
  let finalsHtml = '';
  if (manifest.final_raw_path) {
    finalsHtml += `<div class="card">
      <h3>Raw concat</h3>
      <div class="video-wrap">
        <video controls preload="metadata" src="final-video/raw" data-label="Final raw"></video>
      </div>
    </div>`;
  }
  if (manifest.final_fast_path) {
    finalsHtml += `<div class="card">
      <h3>Fast concat</h3>
      <div class="video-wrap">
        <video controls preload="metadata" src="final-video/fast" data-label="Final fast"></video>
      </div>
    </div>`;
  }
  finalsEl.innerHTML = finalsHtml;
  finalsEl.style.display = finalsHtml ? 'flex' : 'none';

  // Timeline strip
  const timelineEl = document.getElementById('timeline');
  if (clipCount > 0) {
    timelineEl.style.display = 'flex';
    timelineEl.innerHTML = manifest.clips.map((c, i) => {
      const n = i + 1;
      const hasSpeedup = (c.speedup_events || []).length > 0;
      const cls = hasSpeedup ? 'tl-step speedup' : 'tl-step';
      const markHtml = hasSpeedup
        ? `<span class="mark">โšก ${esc(c.speedup_events[0].speed)}x</span>`
        : '';
      return `<a class="${cls}" href="#clip-${esc(c.id)}">
        <span class="num">${n}</span>
        <span>${esc(c.title || c.id).slice(0, 30)}</span>
        ${markHtml}
      </a>`;
    }).join('');
  } else {
    timelineEl.style.display = 'none';
  }

  // Step cards
  const stepsEl = document.getElementById('steps');
  if (clipCount === 0) {
    stepsEl.innerHTML = `<div class="empty-state">
      <strong>No clips yet.</strong><br><br>
      Your demo script should call <code>adom-video-post manifest add</code> once per completed step.<br>
      The UI live-updates as clips are added.
    </div>`;
    return;
  }
  stepsEl.innerHTML = manifest.clips.map((c, i) => renderClip(c, i + 1)).join('');

  // Wire up lightbox on newly-rendered thumbnails
  document.querySelectorAll('.step-card .thumb').forEach(el => {
    el.addEventListener('click', (e) => {
      e.stopPropagation();
      const img = document.getElementById('lightbox-img');
      const cap = document.getElementById('lightbox-caption');
      img.src = el.src;
      cap.textContent = el.getAttribute('alt') || '';
      document.getElementById('lightbox').classList.add('open');
    });
  });
}

function renderClip(c, n) {
  const speedupPill = (c.speedup_events || []).length > 0
    ? `<span class="speedup-pill">โšก ${esc(c.speedup_events[0].speed)}x ${esc(c.speedup_events[0].label)}</span>`
    : '';
  // "Just updated" badge โ€” clip's raw_mtime_ms exceeds the user's
  // last-seen marker. Captured during render so layout knows whether
  // to add the .is-new class to the card for ring highlighting.
  const isNew = (window.__appCtx && window.__appCtx.manifest)
    ? isNewSinceLastSeen(window.__appCtx.manifest, c)
    : false;
  const updatedAt = isNew ? relativeUpdated(c.raw_mtime_ms) : '';
  // Sentence case (per human-ui-patterns ยง1e: never ALL CAPS for status
  // pills). Tooltip explains when it changed AND why a NEW badge is
  // showing โ€” the user shouldn't have to guess (per ยง1a). No emoji
  // (the ๐Ÿ†• glyph already contains the word "NEW", produces a
  // double-decker label).
  const newTooltip = isNew
    ? `Updated ${updatedAt}. This clip was re-recorded since your last reload โ€” focus your review here. The badge auto-clears after 30 seconds on the page.`
    : '';
  const newPill = isNew
    ? `<span class="new-pill" data-tooltip="${esc(newTooltip)}">
         <span class="new-pill-dot" aria-hidden="true"></span>
         <span class="new-pill-label">Just updated</span>
         <span class="new-pill-when">ยท ${esc(updatedAt)}</span>
       </span>`
    : '';
  const description = c.description ? `<div class="desc">${esc(c.description)}</div>` : '';
  const warnings = (c.warnings || []).length > 0
    ? `<div class="warnings">โš  ${c.warnings.map(esc).join('<br>โš  ')}</div>`
    : '';
  const actions = (c.actions || []).length > 0
    ? `<div class="actions-list">${c.actions.map(a => esc(a)).map(a => 'โ€ข ' + a).join('<br>')}</div>`
    : '';

  // Thumbnails: image_path (step screenshot) + console_png_path (data close-up)
  const thumbs = [];
  if (c.image_path) {
    thumbs.push(`<img class="thumb" src="image/${esc(c.id)}" alt="${esc(c.title)} โ€” screenshot">`);
  }
  if (c.console_png_path) {
    thumbs.push(`<img class="thumb" src="console-png/${esc(c.id)}" alt="${esc(c.title)} โ€” console output">`);
  }
  const thumbRow = thumbs.length > 0
    ? `<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;">${thumbs.join('')}</div>`
    : '';

  // Video blocks. Label is "Recorded clip" โ€” note this is post-mux: TTS
  // narration audio has been merged into the silent screen recording.
  // The truly-raw silent capture isn't surfaced (the narrated version is
  // what the demo actually ships).
  let rawBlock = '';
  if (c.raw_path) {
    rawBlock = `<div class="clip-block">
      <div class="clip-label raw">Recorded clip <span class="clip-label-hint">(silent capture + TTS audio muxed)</span></div>
      <div class="video-wrap">
        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/raw" data-label="${esc(c.title)} raw"></video>
      </div>
    </div>`;
  }
  let fastBlock = '';
  if (c.fast_path) {
    fastBlock = `<div class="clip-block">
      <div class="clip-label fast">Fast (after speedup)</div>
      <div class="video-wrap">
        <video controls preload="metadata" poster="poster/${esc(c.id)}" src="video/${esc(c.id)}/fast" data-label="${esc(c.title)} fast"></video>
      </div>
    </div>`;
  }

  // Collapsible narration script (TTS source text). Distinct from the
  // intent description above. Folded by default; click to read what
  // the viewer will hear.
  const narrationBlock = c.narration
    ? `<details class="narration-fold">
         <summary>โ–ธ TTS narration</summary>
         <div class="narration-body">${esc(c.narration)}</div>
       </details>`
    : '';

  return `<div class="step-card${isNew ? ' is-new' : ''}" id="clip-${esc(c.id)}" data-clip-id="${esc(c.id)}">
    <h2><span class="badge">${n}</span>${esc(c.title || c.id)}${speedupPill}${newPill}</h2>
    ${description}
    ${narrationBlock}
    ${warnings}
    ${thumbRow}
    ${actions}
    <div class="clip-row">${rawBlock}${fastBlock}</div>
  </div>`;
}

// --- fetch + live watch ---
async function fetchManifest() {
  const r = await fetch('manifest');
  if (!r.ok) throw new Error('GET /manifest ' + r.status);
  return r.json();
}
async function fetchState() {
  const r = await fetch('state');
  if (!r.ok) throw new Error('GET /state ' + r.status);
  return r.json();
}

// Auto-mark NEW clips as seen after a delay so the badge fades next reload.
// Triggered after every render โ€” we wait 30 s once the user has had a real
// chance to look at fresh clips. Resets on every render that adds new
// "is-new" cards (so re-shoots restart the timer).
let __markSeenTimer = null;
function scheduleMarkSeen(manifest) {
  if (__markSeenTimer) clearTimeout(__markSeenTimer);
  __markSeenTimer = setTimeout(() => {
    if (!manifest || !manifest.clips) return;
    manifest.clips.forEach(c => markClipSeen(manifest, c));
    console.log('auto mark-seen: cleared NEW badge state for', manifest.clips.length, 'clips');
  }, 30000);
}

async function pollLoop() {
  while (true) {
    try {
      const s = await fetchState();
      if (s.version !== window.__appCtx.version) {
        const prevVersion = window.__appCtx.version;
        const m = await fetchManifest();
        // First-visit: silently record current mtimes so nothing
        // is flagged "Just updated" on initial load. Only flag clips
        // whose mtime > previously-stored last-seen on later visits.
        const firstVisit = isFirstVisit(m);
        if (firstVisit) {
          m.clips.forEach(c => markClipSeen(m, c));
          markManifestEverSeen(m);
        }
        window.__appCtx.manifest = m;
        window.__appCtx.version = s.version;
        if (!firstVisit) scheduleMarkSeen(m);
        if (prevVersion > 0) {
          // Version bumped AFTER initial load โ€” this means a live
          // watcher update just fired. Mark it so the pill shows
          // "Live" for 5 seconds.
          window.__appCtx.lastManifestChange = Date.now();
        }
        renderManifest(m, s.version);
        console.log('manifest loaded: version', s.version, 'clips', m.clips.length);
      } else if (window.__appCtx.lastManifestChange &&
                 (Date.now() - window.__appCtx.lastManifestChange) >= 5000) {
        // Recompute pill when the "Live" timeout expires.
        if (window.__appCtx.manifest) {
          renderManifest(window.__appCtx.manifest, s.version);
        }
        window.__appCtx.lastManifestChange = null;
      }
    } catch (e) {
      console.warn('pollLoop error:', e.message);
    }
    await new Promise(r => setTimeout(r, 800));
  }
}

// --- URL hash remote-control (same contract as demo-dry-run.py) ---
function handleTestHash() {
  const m = /^#test=expand-(final|\S+)-(raw|fast)$/.exec(window.location.hash);
  if (!m) return;
  const which = m[1], variant = m[2];
  let needle;
  if (which === 'final') {
    needle = variant === 'raw' ? 'Final raw' : 'Final fast';
  } else {
    // Per-clip expand: find the video whose data-label starts with
    // the clip's title. Use the clip id as the needle first because
    // ids are stable; fall back to title.
    const clip = (window.__appCtx.manifest?.clips || []).find(c => c.id === which);
    if (clip) {
      needle = clip.title + ' ' + variant;
    } else {
      return;
    }
  }
  const target = Array.from(document.querySelectorAll('.video-wrap video'))
    .find(v => (v.getAttribute('data-label') || '').indexOf(needle) !== -1);
  if (target) {
    const btn = target.parentElement.querySelector('.expand');
    if (btn) btn.click();
  }
}
window.addEventListener('load', () => {
  pollLoop();
  setTimeout(handleTestHash, 500);
});
window.addEventListener('hashchange', handleTestHash);
</script>
</body>
</html>