<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>flicker — agent-first terminal text animations</title>
<meta name="description" content="Agent-first terminal text animations. Library + MCP server + CLI. Five verb-first tools: play_typewriter, play_spinner, play_progress, estimate, overview." />
<meta name="theme-color" content="#0a0e1a" />

<!-- Open Graph -->
<meta property="og:title" content="flicker — agent-first terminal text animations" />
<meta property="og:description" content="Typewriter, spinner, progress — for agents that already live in a terminal. MCP-first, MCP-stdio safe." />
<meta property="og:url" content="https://flicker.sh" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://flicker.sh/og-image.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="A terminal window showing flicker's typewriter, spinner, and progress effects, each rendered to completion. Wordmark 'flicker_' in orange." />
<meta property="og:site_name" content="flicker" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="flicker — agent-first terminal text animations" />
<meta name="twitter:description" content="Typewriter, spinner, progress — for agents that already live in a terminal. MCP-first, MCP-stdio safe." />
<meta name="twitter:image" content="https://flicker.sh/og-image.png" />
<meta name="twitter:image:alt" content="A terminal window showing flicker's three v0.1 effects rendered to completion." />

<!-- Agent-discovery surfaces -->
<link rel="agents" href="/AGENTS.md" />
<link rel="alternate" type="text/markdown" href="/AGENTS.md" title="AGENTS.md" />
<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt" />

<style>
  :root {
    --bg: #0a0e1a;
    --bg-elev: #121828;
    --bg-code: #0d1320;
    --border: #1e2640;
    --text: #e5e9f0;
    --text-dim: #8b95b5;
    --text-faint: #5a6485;
    --accent: #ffb86c;
    --accent-dim: #d99858;
    --link: #88c0d0;
    --link-hover: #a3d4e0;
    --green: #8fbc8f;
    --red: #e88080;
    --code-font: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
    --body-font: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
  }

  * { box-sizing: border-box; }

  html, body {
    margin: 0;
    padding: 0;
    background: var(--bg);
    color: var(--text);
    font-family: var(--body-font);
    line-height: 1.6;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
  }

  main {
    flex: 1;
    max-width: 880px;
    margin: 0 auto;
    padding: 64px 32px 96px;
  }

  /* Hero */
  .hero {
    margin-bottom: 64px;
  }

  .hero h1 {
    font-family: var(--code-font);
    font-size: 88px;
    line-height: 0.9;
    margin: 0 0 16px;
    letter-spacing: -3px;
    background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dim) 100%);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
  }

  .hero .cursor {
    display: inline-block;
    width: 0.5em;
    background: var(--accent);
    color: var(--accent);
    animation: blink 1.05s steps(2, start) infinite;
    -webkit-text-fill-color: var(--accent);
  }

  /* Hero terminal — animated terminal window */
  .hero-terminal {
    margin: 36px 0 32px;
    border-radius: 10px;
    background: #07090f;
    border: 1px solid var(--border);
    box-shadow:
      0 1px 0 rgba(255, 255, 255, 0.04) inset,
      0 24px 60px -20px rgba(0, 0, 0, 0.6),
      0 8px 24px -12px rgba(255, 184, 108, 0.08);
    overflow: hidden;
    position: relative;
  }
  .hero-terminal::after {
    /* subtle scanline glow */
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(ellipse at top, rgba(255, 184, 108, 0.06), transparent 60%);
  }
  .terminal-chrome {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 11px 14px;
    background: linear-gradient(180deg, #141a2a 0%, #0f1422 100%);
    border-bottom: 1px solid var(--border);
  }
  .tl-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    display: inline-block;
  }
  .tl-red    { background: #ff5f57; }
  .tl-yellow { background: #febc2e; }
  .tl-green  { background: #28c840; }
  .terminal-title {
    margin-left: 12px;
    font-family: var(--code-font);
    font-size: 12px;
    color: var(--text-faint);
    user-select: none;
  }
  .terminal-body {
    margin: 0;
    padding: 22px 26px 24px;
    background: transparent;
    border: none;
    border-radius: 0;
    font-family: var(--code-font);
    font-size: 15px;
    line-height: 1.65;
    color: var(--text);
    min-height: 320px;
    overflow-x: auto;
    white-space: pre;
    text-shadow: 0 0 12px rgba(229, 233, 240, 0.04);
  }
  .terminal-body .dim     { color: var(--text-faint); }
  .terminal-body .cmd     { color: var(--text); }
  .terminal-body .accent  { color: var(--accent); }
  .terminal-body .green   { color: var(--green); }
  .terminal-body .arg     { color: var(--link); }
  .terminal-body .pct     { color: var(--text-dim); }
  .terminal-body .hero-blink {
    color: var(--accent);
    animation: blink 1.05s steps(2, start) infinite;
  }

  @keyframes blink {
    to { visibility: hidden; }
  }

  .hero .tagline {
    font-size: 22px;
    color: var(--text-dim);
    margin: 0 0 24px;
    max-width: 640px;
  }

  .badges {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 8px;
  }

  .badge {
    font-family: var(--code-font);
    font-size: 13px;
    padding: 4px 10px;
    border-radius: 4px;
    background: var(--bg-elev);
    border: 1px solid var(--border);
    color: var(--text-dim);
    text-decoration: none;
    transition: border-color 120ms, color 120ms;
  }

  .badge:hover {
    border-color: var(--link);
    color: var(--link);
  }

  .badge .label {
    color: var(--text-faint);
  }

  .badge .value {
    color: var(--accent);
  }

  /* Section */
  section {
    margin: 56px 0;
  }

  section h2 {
    font-size: 24px;
    margin: 0 0 16px;
    font-weight: 600;
    color: var(--text);
    border-bottom: 1px solid var(--border);
    padding-bottom: 8px;
  }

  section p {
    margin: 12px 0;
    color: var(--text-dim);
  }

  section p strong {
    color: var(--text);
  }

  /* Code blocks */
  pre, code {
    font-family: var(--code-font);
    font-size: 14px;
  }

  code {
    background: var(--bg-code);
    border: 1px solid var(--border);
    border-radius: 3px;
    padding: 1px 6px;
    color: var(--accent);
  }

  pre {
    background: var(--bg-code);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 16px 20px;
    overflow-x: auto;
    margin: 12px 0;
    line-height: 1.55;
  }

  pre code {
    background: none;
    border: none;
    padding: 0;
    color: var(--text);
    font-size: 14px;
  }

  pre .prompt {
    color: var(--text-faint);
    user-select: none;
  }

  /* Effect demos (live, JS-driven) */
  .effect-card {
    margin: 28px 0;
  }
  .effect-card h3 {
    font-family: var(--code-font);
    font-size: 18px;
    margin: 0 0 6px;
    color: var(--accent);
  }
  .effect-card > p {
    margin: 0 0 12px;
    color: var(--text-dim);
    font-size: 14px;
  }
  .effect-demo {
    background: #060914;
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 14px 18px;
    margin: 8px 0 0;
    font-family: var(--code-font);
    font-size: 14px;
    line-height: 1.6;
    overflow-x: auto;
  }
  .effect-demo .demo-cmd { color: var(--text-dim); user-select: text; }
  .effect-demo .demo-cmd .prompt { color: var(--text-faint); user-select: none; }
  .effect-demo .demo-out { color: var(--text); white-space: pre; min-height: 1.6em; }
  .effect-demo .demo-out .glyph { color: var(--accent); }
  .effect-demo .demo-out .bar-fill { color: var(--green); }
  .effect-demo .demo-out .bar-empty { color: var(--text-faint); }
  .effect-demo .demo-out .pct { color: var(--text-dim); }
  .effect-demo .demo-out .blink {
    animation: blink 1.05s steps(2, start) infinite;
  }

  /* Copy-on-click for the MCP install line */
  .copyable {
    cursor: text;
    user-select: all;
    -webkit-user-select: all;
    display: block;
    color: var(--accent);
  }

  .copy-hint {
    display: block;
    color: var(--text-faint);
    font-size: 12px;
    margin-top: 8px;
    user-select: none;
  }

  /* Tables */
  table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0;
    font-size: 14px;
  }

  th, td {
    text-align: left;
    padding: 10px 12px;
    border-bottom: 1px solid var(--border);
    vertical-align: top;
  }

  th {
    color: var(--text);
    font-weight: 600;
    background: var(--bg-elev);
  }

  td {
    color: var(--text-dim);
  }

  td:first-child {
    font-family: var(--code-font);
    color: var(--accent);
    white-space: nowrap;
  }

  td code {
    font-size: 13px;
    background: transparent;
    border: none;
    padding: 0;
    color: var(--link);
  }

  /* Links */
  a {
    color: var(--link);
    text-decoration: none;
    border-bottom: 1px solid transparent;
    transition: border-color 120ms, color 120ms;
  }

  a:hover {
    color: var(--link-hover);
    border-bottom-color: var(--link-hover);
  }

  /* Footer */
  footer {
    border-top: 1px solid var(--border);
    padding: 32px;
    text-align: center;
    color: var(--text-faint);
    font-size: 13px;
  }

  footer nav {
    margin-bottom: 16px;
    display: flex;
    gap: 24px;
    justify-content: center;
    flex-wrap: wrap;
  }

  footer nav a {
    color: var(--text-dim);
    border-bottom: none;
  }

  footer nav a:hover {
    color: var(--link);
  }

  /* Mobile */
  @media (max-width: 720px) {
    main {
      padding: 40px 20px 64px;
    }
    .hero h1 {
      font-size: 56px;
      letter-spacing: -2px;
    }
    .hero .tagline {
      font-size: 18px;
    }
    .terminal-body {
      font-size: 13px;
      padding: 18px 18px 20px;
      min-height: 280px;
    }
    .terminal-chrome { padding: 9px 12px; }
    .terminal-title { font-size: 11px; }
    section h2 {
      font-size: 20px;
    }
    pre {
      font-size: 13px;
      padding: 12px 14px;
    }
    table {
      font-size: 13px;
    }
    th, td {
      padding: 8px 10px;
    }
    footer nav {
      gap: 16px;
      font-size: 12px;
    }
  }
</style>
</head>

<body>
<main>

<header class="hero">
  <h1>flicker<span class="cursor">_</span></h1>
  <p class="tagline">Agent-first terminal text animations — library, MCP server, CLI.</p>

  <div class="hero-terminal" aria-hidden="true">
    <div class="terminal-chrome">
      <span class="tl-dot tl-red"></span><span class="tl-dot tl-yellow"></span><span class="tl-dot tl-green"></span>
      <span class="terminal-title">flicker — npx @capitalthought/flicker</span>
    </div>
    <pre class="terminal-body" id="hero-term"><span class="dim">$</span> <span class="hero-blink">_</span></pre>
  </div>

  <div class="badges">
    <a class="badge" href="https://www.npmjs.com/package/@capitalthought/flicker"><span class="label">npm</span> <span class="value">@capitalthought/flicker</span></a>
    <a class="badge" href="https://github.com/capitalthought/flicker"><span class="label">github</span> <span class="value">capitalthought/flicker</span></a>
    <a class="badge" href="/AGENTS.md"><span class="label">contract</span> <span class="value">AGENTS.md</span></a>
  </div>
</header>

<section id="install">
  <h2>Install</h2>
  <p>Install once, or run on demand with <code>npx</code>:</p>
  <pre><code><span class="prompt">$ </span>npm install -g @capitalthought/flicker
<span class="prompt">$ </span>npx @capitalthought/flicker typewriter "hello, flicker"</code></pre>
  <p>Node &gt;= 20. No native deps. Zero secrets at runtime.</p>
</section>

<section id="mcp">
  <h2>Install as an MCP server</h2>
  <p>The MCP server is the primary surface. Wire it into Claude Code with one command:</p>
  <pre><code class="copyable">claude mcp add flicker npx @capitalthought/flicker mcp</code><span class="copy-hint">↑ triple-click to select, or just paste it as-is</span></pre>
  <p>You'll then have five verb-first tools: <code>play_typewriter</code>, <code>play_spinner</code>, <code>play_progress</code>, <code>estimate</code>, <code>overview</code>. The renderer writes to <code>/dev/tty</code>, never to MCP stdio — so JSON-RPC stays clean.</p>
</section>

<section id="effects">
  <h2>Effects</h2>
  <p>Three v0.1 effects. The previews below are HTML approximations of what runs in your real terminal — same cadence, same final frames.</p>

  <div class="effect-card">
    <h3>typewriter</h3>
    <p>Characters appear one at a time at <em>N</em> cps. Determinate — <code>plan()</code> knows the total cost up front.</p>
    <div class="effect-demo">
      <div class="demo-cmd"><span class="prompt">$ </span>flicker typewriter "hello, flicker" --cps 30</div>
      <div class="demo-out" id="demo-tw"><span class="blink">_</span></div>
    </div>
  </div>

  <div class="effect-card">
    <h3>spinner</h3>
    <p>Animated glyph + label. Omit <code>--duration</code> and it runs indeterminate, capped at 60 s (<code>fallback: "duration_cap"</code>).</p>
    <div class="effect-demo">
      <div class="demo-cmd"><span class="prompt">$ </span>flicker spinner "Connecting" --duration 2000 --kind dots</div>
      <div class="demo-out" id="demo-sp"></div>
    </div>
  </div>

  <div class="effect-card">
    <h3>progress</h3>
    <p>Determinate bar with <code>--total</code>, or bouncing indeterminate when <code>total</code> is <code>null</code>.</p>
    <div class="effect-demo">
      <div class="demo-cmd"><span class="prompt">$ </span>flicker progress "Downloading" --total 100 --steps 20</div>
      <div class="demo-out" id="demo-pg"></div>
    </div>
  </div>
</section>

<section id="tools">
  <h2>MCP tools</h2>
  <table>
    <thead>
      <tr><th>Tool</th><th>Purpose</th></tr>
    </thead>
    <tbody>
      <tr>
        <td>play_typewriter</td>
        <td>Render typewriter text. Returns a <code>Receipt</code> with bytes, duration, fallback tier, and cost.</td>
      </tr>
      <tr>
        <td>play_spinner</td>
        <td>Render a spinner. Three kinds: <code>dots</code>, <code>bar</code>, <code>pulse</code>. Hangs are impossible.</td>
      </tr>
      <tr>
        <td>play_progress</td>
        <td>Render a progress bar. Determinate (<code>total: N</code>) or indeterminate (<code>total: null</code>).</td>
      </tr>
      <tr>
        <td>estimate</td>
        <td>Pure planning. Returns a <code>CostEstimate</code> with worst-case and stripped dollar cost. No render.</td>
      </tr>
      <tr>
        <td>overview</td>
        <td>Server status: uptime, mode, terminal caps, last 20 receipts, cumulative cost. Inspectable State.</td>
      </tr>
    </tbody>
  </table>
</section>

<section id="why">
  <h2>Why agent-first</h2>
  <p>Agents now run inside terminals. They want decorative output — a typewriter intro, a progress bar, a spinner — without three failure modes that ordinary animation libraries don't handle:</p>
  <p><strong>MCP stdio safety.</strong> Any ANSI escape on a server's stdout breaks JSON-RPC; the host drops the connection. flicker's renderer writes to <code>/dev/tty</code> (or stderr, or nothing) — never to MCP stdout. Three rendering tiers — <code>full</code>, <code>reduced</code>, <code>plain</code> — are auto-selected based on <code>/dev/tty</code>, <code>MCP_CLIENT</code>, <code>AGENT</code>, <code>CI</code>, and <code>NO_COLOR</code>.</p>
  <p><strong>Cost transparency.</strong> A two-sentence model reply that ran a spinner during the call has been measured at ~15KB of escape noise to ~200 bytes of content. Every effect can be <code>estimate</code>'d without rendering — <code>dollars_worst_case</code> is what you'd pay if every escape byte hit your context, <code>dollars_stripped</code> is what you'd pay after stripping. The gap is the ANSI tax.</p>
  <p><strong>CSI allowlist.</strong> LLM-generated output can embed CSI / OSC / DCS sequences that hijack a terminal. flicker emits only a small allowlisted set and rejects caller text that smuggles its own (no OSC 8 hyperlinks, no OSC 52 clipboard, no mouse modes, no Sixel).</p>
</section>

</main>

<footer>
  <nav>
    <a href="/AGENTS.md">AGENTS.md</a>
    <a href="/llms.txt">llms.txt</a>
    <a href="https://www.npmjs.com/package/@capitalthought/flicker">npm</a>
    <a href="https://github.com/capitalthought/flicker">GitHub</a>
  </nav>
  <div>&copy; Capital Thought, LLC. MIT licensed.</div>
</footer>

<script>
(() => {
  const tw = document.getElementById('demo-tw');
  const sp = document.getElementById('demo-sp');
  const pg = document.getElementById('demo-pg');
  const hero = document.getElementById('hero-term');

  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  // ── HERO TERMINAL — sequential storytelling ─────────────────
  if (hero) {
    const SP_GLYPHS = [".  ", ".. ", "...", " ..", "  .", "   "];
    const PG_WIDTH = 24;
    const PROMPT = '<span class="dim">$</span> ';
    const BLINK = '<span class="hero-blink">_</span>';
    const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

    // Lines render as HTML; commands type out as plain text then finalize
    // to a syntax-highlighted version (so we never mid-slice an HTML tag).
    let lines = [];
    const render = (tail = '') => { hero.innerHTML = lines.join('\n') + tail; };

    // Type plain text into lines[idx] one char at a time.
    const typePlain = async (idx, prefixHTML, plainText, msPerChar) => {
      for (let i = 0; i <= plainText.length; i++) {
        lines[idx] = prefixHTML + esc(plainText.slice(0, i));
        render(BLINK);
        await sleep(msPerChar);
      }
    };

    // After typing finishes, swap the line for a styled version.
    const highlightCommand = (cmd, quotedArg) => {
      // cmd e.g. 'flicker typewriter ' (trailing space), quotedArg e.g. '"hello, flicker"'
      // tail is whatever comes after the quoted arg (e.g. ' --cps 30').
      const idxOfArg = cmd.indexOf(quotedArg);
      const before = cmd.slice(0, idxOfArg);
      const after = cmd.slice(idxOfArg + quotedArg.length);
      return esc(before) + '<span class="arg">' + esc(quotedArg) + '</span>' + esc(after);
    };

    const setLine = (idx, html) => { lines[idx] = html; render(); };

    const finalFrame = () => {
      lines = [
        PROMPT + highlightCommand('flicker typewriter "hello, flicker"', '"hello, flicker"'),
        'hello, flicker',
        '',
        PROMPT + highlightCommand('flicker spinner "Connecting" --duration 2000', '"Connecting"'),
        '<span class="accent">' + SP_GLYPHS[2] + '</span> Connecting',
        '',
        PROMPT + highlightCommand('flicker progress "Downloading" --total 100 --steps 24', '"Downloading"'),
        '[<span class="green">' + '#'.repeat(PG_WIDTH) + '</span>] <span class="pct">100%</span> Downloading',
      ];
      render();
    };

    if (reduced) {
      finalFrame();
    } else {
      (async function loop() {
        while (true) {
          // ░ stage 1 — typewriter
          lines = [PROMPT];
          render(BLINK);
          await sleep(800);
          const cmd1 = 'flicker typewriter "hello, flicker"';
          await typePlain(0, PROMPT, cmd1, 38);
          setLine(0, PROMPT + highlightCommand(cmd1, '"hello, flicker"'));
          await sleep(260);
          lines.push('');
          await typePlain(1, '', 'hello, flicker', 78);
          await sleep(1100);

          // ░ stage 2 — spinner
          lines.push('');                          // blank separator
          lines.push(PROMPT);                      // new prompt
          const cmd2 = 'flicker spinner "Connecting" --duration 2000';
          await typePlain(3, PROMPT, cmd2, 30);
          setLine(3, PROMPT + highlightCommand(cmd2, '"Connecting"'));
          await sleep(220);
          lines.push('');                          // output line
          const SP_FPS = 15;
          const SP_FRAMES = SP_FPS * 2;            // 2s
          for (let f = 0; f < SP_FRAMES; f++) {
            lines[4] = '<span class="accent">' + SP_GLYPHS[f % SP_GLYPHS.length] + '</span> Connecting';
            render();
            await sleep(1000 / SP_FPS);
          }
          lines[4] = '<span class="accent">' + SP_GLYPHS[(SP_FRAMES - 1) % SP_GLYPHS.length] + '</span> Connecting';
          render();
          await sleep(900);

          // ░ stage 3 — progress
          lines.push('');
          lines.push(PROMPT);
          const cmd3 = 'flicker progress "Downloading" --total 100 --steps 24';
          await typePlain(6, PROMPT, cmd3, 26);
          setLine(6, PROMPT + highlightCommand(cmd3, '"Downloading"'));
          await sleep(200);
          lines.push('');
          for (let s = 0; s <= PG_WIDTH; s++) {
            const fill = '#'.repeat(s);
            const empty = '.'.repeat(PG_WIDTH - s);
            const pct = Math.round((s / PG_WIDTH) * 100);
            lines[7] = '[<span class="green">' + fill + '</span><span class="dim">' + empty + '</span>] <span class="pct">' + String(pct).padStart(3, ' ') + '%</span> Downloading';
            render();
            await sleep(70);
          }
          await sleep(2600);

          // ░ fade-out, reset, next cycle
          hero.style.transition = 'opacity 0.45s ease';
          hero.style.opacity = '0';
          await sleep(450);
          hero.style.opacity = '1';
          await sleep(60);
        }
      })();
    }
  }

  if (!tw || !sp || !pg) return;

  // ── typewriter ──────────────────────────────────────────────
  const TW_TEXT = "hello, flicker";
  // Real lib runs ~30cps (33ms/char). Browser demo slows to ~11cps
  // so the eye can track it; the CLI string above still says --cps 30.
  const TW_MS = 90;
  if (reduced) {
    tw.textContent = TW_TEXT;
  } else {
    let i = 0;
    const tick = () => {
      if (i <= TW_TEXT.length) {
        tw.innerHTML = TW_TEXT.slice(0, i).replace(/</g, '&lt;') + '<span class="blink">_</span>';
        i++;
        setTimeout(tick, TW_MS);
      } else {
        setTimeout(() => { i = 0; tick(); }, 2400);
      }
    };
    tick();
  }

  // ── spinner ────────────────────────────────────────────────
  const SP_GLYPHS = [".  ", ".. ", "...", " ..", "  .", "   "];
  const SP_LABEL = "Connecting";
  const SP_FPS = 15;
  const SP_DURATION_MS = 2000;
  const SP_TOTAL = Math.round(SP_DURATION_MS / 1000 * SP_FPS);
  const SP_FRAME = Math.round(1000 / SP_FPS);
  if (reduced) {
    sp.innerHTML = '<span class="glyph">' + SP_GLYPHS[2] + '</span> ' + SP_LABEL;
  } else {
    let f = 0;
    const tick = () => {
      if (f < SP_TOTAL) {
        sp.innerHTML = '<span class="glyph">' + SP_GLYPHS[f % SP_GLYPHS.length] + '</span> ' + SP_LABEL;
        f++;
        setTimeout(tick, SP_FRAME);
      } else {
        // final frame held briefly, then loop
        sp.innerHTML = '<span class="glyph">' + SP_GLYPHS[(f - 1) % SP_GLYPHS.length] + '</span> ' + SP_LABEL;
        setTimeout(() => { f = 0; tick(); }, 1800);
      }
    };
    tick();
  }

  // ── progress ───────────────────────────────────────────────
  const PG_WIDTH = 20;
  const PG_STEP_MS = 80;
  const PG_LABEL = "Downloading";
  const renderPg = (step) => {
    const filled = '#'.repeat(step);
    const empty = '.'.repeat(PG_WIDTH - step);
    const pct = Math.round((step / PG_WIDTH) * 100);
    return '[<span class="bar-fill">' + filled + '</span><span class="bar-empty">' + empty + '</span>] <span class="pct">' + String(pct).padStart(3, ' ') + '%</span> ' + PG_LABEL;
  };
  if (reduced) {
    pg.innerHTML = renderPg(PG_WIDTH);
  } else {
    let s = 0;
    const tick = () => {
      if (s <= PG_WIDTH) {
        pg.innerHTML = renderPg(s);
        s++;
        setTimeout(tick, PG_STEP_MS);
      } else {
        setTimeout(() => { s = 0; tick(); }, 2400);
      }
    };
    tick();
  }
})();
</script>

</body>
</html>
