[object Object]

← back to Sound Agentabrams

sound.agentabrams.com: SoundLab index — same setup as games, with per-item icon + open-in-own-window

713ea31025216bfdee6c5365fc01c3ed45fdae69 · 2026-07-24 23:58:48 -0700 · Steve

Files touched

Diff

commit 713ea31025216bfdee6c5365fc01c3ed45fdae69
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 24 23:58:48 2026 -0700

    sound.agentabrams.com: SoundLab index — same setup as games, with per-item icon + open-in-own-window
---
 .gitignore                         |  11 ++
 README.md                          |  26 +++
 deploy/deploy.sh                   |  47 +++++
 deploy/sound.agentabrams.com.nginx |  38 ++++
 index.html                         | 360 +++++++++++++++++++++++++++++++++++++
 sound.json                         |   3 +
 sounds/.gitkeep                    |   0
 7 files changed, 485 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8f85f84
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+tests/screenshot.png
+
+tests/rec/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9dcdd6b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# sound.agentabrams.com — Agent Abrams SoundLab
+
+Static sound/audio-toy hub, same setup as games.agentabrams.com: left panel lists
+every build (`*.aa` names + icon), right panel runs the selected one in an iframe,
+and each build can be popped **into its own window** (⧉). Zero dependencies, zero build.
+
+Live target: https://sound.agentabrams.com (Kamatera, `/var/www/sound.agentabrams.com`, nginx static)
+
+## Adding a sound build
+
+1. Drop a self-contained build at `sounds/<id>/index.html` (must run standalone in an iframe).
+2. Add its `<Name>.aa` marker in that folder, and an entry to `sound.json`:
+
+```json
+{ "id": "<id>", "file": "<Name>.aa", "icon": "🔊", "title": "...", "desc": "...", "path": "sounds/<id>/", "added": "YYYY-MM-DD" }
+```
+
+The hub auto-selects the last-opened build (localStorage) and supports `#<id>` deep links.
+Every card opens in the embedded stage on click, or in its own window via the ⧉ pop-out.
+
+## Deploy (Steve-gated)
+
+```sh
+deploy/deploy.sh        # first deploy: DNS + rsync + nginx + SSL
+# updates: rsync -az --delete --exclude .git --exclude deploy ./ root@45.61.58.125:/var/www/sound.agentabrams.com/
+```
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..3659505
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+# sound.agentabrams.com — full first deploy (DNS + rsync + nginx + SSL).
+# Steve-gated: run only with explicit approval.
+set -euo pipefail
+KAMATERA=root@45.61.58.125
+SITE=sound.agentabrams.com
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+
+# 1. DNS: A record sound -> Kamatera (DNS-only, matches fleet pattern)
+export $(grep -E "^CLOUDFLARE_API_TOKEN=" ~/Projects/secrets-manager/.env)
+ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+  "https://api.cloudflare.com/client/v4/zones?name=agentabrams.com" \
+  | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
+EXISTS=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+  "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?name=$SITE" \
+  | python3 -c "import sys,json; print(len(json.load(sys.stdin)['result']))")
+if [ "$EXISTS" = "0" ]; then
+  curl -s -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
+    "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" \
+    -d "{\"type\":\"A\",\"name\":\"sound\",\"content\":\"45.61.58.125\",\"ttl\":300,\"proxied\":false}" \
+    | python3 -c "import sys,json; r=json.load(sys.stdin); assert r['success'], r['errors']; print('DNS record created')"
+else
+  echo "DNS record already exists"
+fi
+
+# 2. Content
+rsync -az --exclude .git --exclude tests --exclude deploy "$HERE/" $KAMATERA:/var/www/$SITE/
+echo "content rsynced"
+
+# 3. nginx (80 first so certbot HTTP-01 can pass)
+scp -q "$HERE/deploy/$SITE.nginx" $KAMATERA:/etc/nginx/sites-available/$SITE
+ssh $KAMATERA "ln -sf /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE && nginx -t && systemctl reload nginx"
+echo "nginx staged"
+
+# 4. Wait for DNS, then SSL
+for i in $(seq 1 30); do
+  [ -n "$(dig +short $SITE @1.1.1.1)" ] && break
+  sleep 10
+done
+ssh $KAMATERA "certbot --nginx -d $SITE --non-interactive --agree-tos -m steve@designerwallcoverings.com && systemctl reload nginx"
+
+# 5. Smoke test
+sleep 2
+code=$(curl -s -o /dev/null -w '%{http_code}' https://$SITE/)
+echo "https://$SITE -> HTTP $code"
+curl -s https://$SITE/sound.json | head -c 200; echo
+[ "$code" = "200" ] && echo "DEPLOY OK" || { echo "DEPLOY FAILED"; exit 1; }
diff --git a/deploy/sound.agentabrams.com.nginx b/deploy/sound.agentabrams.com.nginx
new file mode 100644
index 0000000..4280b3b
--- /dev/null
+++ b/deploy/sound.agentabrams.com.nginx
@@ -0,0 +1,38 @@
+# sound.agentabrams.com — Agent Abrams SoundLab (public static sound hub)
+server {
+    server_name sound.agentabrams.com;
+
+    access_log /var/log/nginx/sound.agentabrams.com.access.log;
+    error_log /var/log/nginx/sound.agentabrams.com.error.log;
+
+    add_header X-Content-Type-Options "nosniff" always;
+    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+    root /var/www/sound.agentabrams.com;
+    index index.html;
+    autoindex off;
+
+    location / {
+        try_files $uri $uri/ $uri.html =404;
+    }
+
+    listen 45.61.58.125:443 ssl; # managed by Certbot
+    ssl_certificate /etc/letsencrypt/live/sound.agentabrams.com/fullchain.pem; # managed by Certbot
+    ssl_certificate_key /etc/letsencrypt/live/sound.agentabrams.com/privkey.pem; # managed by Certbot
+    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
+}
+
+server {
+    if ($host = sound.agentabrams.com) {
+        return 301 https://$host$request_uri;
+    } # managed by Certbot
+
+
+    listen 45.61.58.125:80;
+    server_name sound.agentabrams.com;
+    return 404; # managed by Certbot
+
+
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ec34239
--- /dev/null
+++ b/index.html
@@ -0,0 +1,360 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>sound.agentabrams.com</title>
+<style>
+  * { box-sizing: border-box; }
+  html, body { height: 100%; margin: 0; }
+  body {
+    display: flex; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    background: #12141a; color: #e8e9ef; overflow: hidden;
+  }
+  /* ---- left panel ---- */
+  #panel {
+    width: 260px; min-width: 260px; height: 100%; display: flex; flex-direction: column;
+    background: linear-gradient(180deg, #1a1e28, #14171f);
+    border-right: 1px solid #2b3140;
+  }
+  #brand {
+    padding: 18px 16px 14px; border-bottom: 1px solid #2b3140;
+  }
+  #brand .dot { color: #7c5cff; }
+  #brand h1 {
+    margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 1.5px;
+  }
+  #brand .sub { font-size: 10px; opacity: .45; letter-spacing: 1px; margin-top: 4px; }
+  #list { flex: 1; overflow-y: auto; padding: 10px 8px; }
+  #list .dir { font-size: 10px; opacity: .4; letter-spacing: 1px; padding: 4px 8px 8px; }
+  .game {
+    display: flex; align-items: flex-start; gap: 8px; width: 100%; text-align: left; cursor: pointer;
+    background: none; border: 1px solid transparent; border-radius: 8px;
+    color: #e8e9ef; font: inherit; padding: 9px 10px; margin-bottom: 2px;
+  }
+  .game:hover { background: #222839; }
+  .game.active { background: #2a3147; border-color: #3d4763; }
+  .game .ficon { font-size: 16px; line-height: 1.25; flex: 0 0 auto; width: 20px; text-align: center; }
+  .game .fbody { flex: 1; min-width: 0; }
+  .game .fname { font-size: 13px; font-weight: 600; }
+  .game.active .fname::before { content: "▾ "; color: #7c5cff; }
+  .game .fdesc { font-size: 10.5px; opacity: .5; margin-top: 3px; line-height: 1.4;
+    display: none; }
+  .game.active .fdesc { display: block; }
+  /* open-in-own-window pop-out button */
+  .game .popout {
+    flex: 0 0 auto; align-self: center; cursor: pointer;
+    font-size: 13px; line-height: 1; color: #8a93a8; opacity: 0;
+    background: none; border: 1px solid transparent; border-radius: 6px; padding: 4px 6px;
+    transition: opacity .15s, background .15s, color .15s;
+  }
+  .game:hover .popout, .game.active .popout { opacity: .8; }
+  .game .popout:hover { opacity: 1; color: #ffd23e; background: #1b2030; border-color: #3d4763; }
+  /* stage pop-out for the current game */
+  #popCurrent {
+    position: absolute; top: 12px; right: 12px; z-index: 5; cursor: pointer;
+    font-family: inherit; font-size: 11px; letter-spacing: .5px; color: #cdd3e0;
+    background: rgba(20,23,31,.72); border: 1px solid #2b3140; border-radius: 8px;
+    padding: 7px 11px; backdrop-filter: blur(6px); display: none;
+  }
+  #popCurrent:hover { color: #ffd23e; border-color: #3d4763; }
+  #panel footer {
+    padding: 12px 16px; border-top: 1px solid #2b3140; font-size: 10px; opacity: .4;
+    letter-spacing: .5px; line-height: 1.6;
+  }
+  /* ---- right panel ---- */
+  #stage { flex: 1; height: 100%; position: relative; background: #0e1016; }
+  #frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
+  #empty {
+    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+    flex-direction: column; gap: 10px; opacity: .35; font-size: 13px; letter-spacing: 1px;
+  }
+  #empty .big { font-size: 40px; }
+  /* ---- mobile ---- */
+  @media (max-width: 700px) {
+    body { flex-direction: column; }
+    #panel { width: 100%; min-width: 0; height: auto; max-height: 45%; }
+    #panel footer { display: none; }
+  }
+  /* ---- onload hero: fireworks motion-graphics splash ---- */
+  #hero {
+    position: fixed; inset: 0; z-index: 9999; background: #06070c;
+    display: flex; flex-direction: column; align-items: center; justify-content: center;
+    overflow: hidden; cursor: pointer; transition: opacity .55s ease;
+  }
+  #hero.hide { opacity: 0; pointer-events: none; }
+  #herofx { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
+  #hero .wrap {
+    position: relative; z-index: 2; text-align: center; pointer-events: none; padding: 20px;
+    animation: heroin 1s cubic-bezier(.2,.9,.2,1) both;
+  }
+  @keyframes heroin {
+    0% { transform: scale(.55); opacity: 0; filter: blur(10px); }
+    60% { opacity: 1; }
+    100% { transform: scale(1); opacity: 1; filter: blur(0); }
+  }
+  #hero h1 {
+    margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    font-weight: 800; letter-spacing: 6px; color: #fff; line-height: 1.04;
+    font-size: clamp(30px, 7.5vw, 76px);
+    text-shadow: 0 0 18px rgba(124,92,255,.9), 0 0 44px rgba(124,92,255,.5);
+  }
+  #hero h1 .a { color: #7c5cff; }
+  #hero .tag {
+    margin-top: 16px; font-family: ui-monospace, Menlo, monospace;
+    font-size: clamp(11px, 2vw, 15px); letter-spacing: 4px; color: #aeb7c7; opacity: .85;
+  }
+  #hero .enter {
+    margin-top: 34px; pointer-events: auto; cursor: pointer;
+    font-family: ui-monospace, Menlo, monospace; font-weight: 700; letter-spacing: 2px;
+    font-size: 15px; color: #06070c; background: #ffd23e; border: 0; border-radius: 999px;
+    padding: 13px 32px; opacity: 0; transition: opacity .5s ease;
+    animation: heropulse 1.15s ease-in-out infinite;
+  }
+  #hero .enter.show { opacity: 1; }        /* reveal driven by JS, not animation-fill */
+  @keyframes heropulse {
+    0%,100% { transform: scale(1); box-shadow: 0 0 24px rgba(255,210,62,.55); }
+    50% { transform: scale(1.06); box-shadow: 0 0 42px rgba(255,210,62,.9); }
+  }
+  #hero .skip {
+    position: absolute; bottom: 16px; right: 18px; z-index: 3; pointer-events: auto;
+    cursor: pointer; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px;
+    color: #8ea; opacity: .45; background: none; border: 0;
+  }
+  #hero .skip:hover { opacity: .9; }
+  @media (prefers-reduced-motion: reduce) {
+    #hero .wrap { animation: none; }
+    #hero .enter { animation: none; opacity: 1; }
+  }
+</style>
+</head>
+<body>
+<div id="hero">
+  <canvas id="herofx"></canvas>
+  <div class="wrap">
+    <h1>AGENT <span class="a">ABRAMS</span><br>SOUNDLAB</h1>
+    <div class="tag">▶ PRESS START</div>
+    <button class="enter" id="heroEnter">ENTER SOUNDLAB</button>
+  </div>
+  <button class="skip" id="heroSkip">skip ✕</button>
+</div>
+<aside id="panel">
+  <div id="brand">
+    <h1><span class="dot">●</span> AGENT ABRAMS SOUNDLAB</h1>
+    <div class="sub">sound.agentabrams.com</div>
+  </div>
+  <nav id="list"><div class="dir">~/sounds/</div></nav>
+  <footer>zero dependencies · pure browser<br>more games landing over time</footer>
+</aside>
+<main id="stage">
+  <button id="popCurrent" title="Open this game in its own window">⧉ own window</button>
+  <iframe id="frame" title="game" hidden></iframe>
+  <div id="empty"><div class="big">🔊</div><div>select a sound</div></div>
+</main>
+<script>
+(function () {
+  var list = document.getElementById('list');
+  var frame = document.getElementById('frame');
+  var empty = document.getElementById('empty');
+  var popCurrent = document.getElementById('popCurrent');
+  var games = [], buttons = {}, DEFAULT_ICON = '🔊';
+
+  // open a game in its OWN browser window (not the iframe)
+  function openWindow(g) {
+    if (!g) return;
+    var w = Math.min(1200, Math.round(screen.availWidth * 0.8));
+    var h = Math.min(820, Math.round(screen.availHeight * 0.85));
+    var feat = 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no,status=no';
+    window.open(g.path + 'index.html', 'aa_' + g.id, feat);
+  }
+
+  function select(id, push) {
+    var g = games.find(function (x) { return x.id === id; });
+    if (!g) return;
+    Object.keys(buttons).forEach(function (k) { buttons[k].classList.toggle('active', k === id); });
+    frame.hidden = false; empty.style.display = 'none';
+    popCurrent.style.display = 'block';
+    popCurrent.onclick = function () { openWindow(g); };
+    // keyboard-primary games (e.g. Skyfire) need the iframe focused or key events
+    // never reach them — setting src alone does NOT focus an iframe.
+    frame.onload = function () { try { frame.focus(); if (frame.contentWindow) frame.contentWindow.focus(); } catch (e) {} };
+    frame.src = g.path + 'index.html';
+    document.title = g.file + ' — sound.agentabrams.com';
+    try { localStorage.setItem('aa-last-sound', id); } catch (e) {}
+    if (push !== false) history.replaceState(null, '', '#' + id);
+  }
+
+  // cache-bust so a newly-added game shows up without a hard refresh
+  fetch('sound.json?cb=' + Date.now(), { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (data) {
+    games = data.sounds || data.games || [];
+    games.forEach(function (g) {
+      var b = document.createElement('button');
+      b.className = 'game';
+      b.innerHTML = '<span class="ficon"></span>' +
+                    '<span class="fbody"><div class="fname"></div><div class="fdesc"></div></span>' +
+                    '<span class="popout" role="button" tabindex="0" title="Open in its own window">⧉</span>';
+      b.querySelector('.ficon').textContent = g.icon || DEFAULT_ICON;
+      b.querySelector('.fname').textContent = g.file;
+      b.querySelector('.fdesc').textContent = g.desc || '';
+      b.onclick = function () { select(g.id); };
+      var pop = b.querySelector('.popout');
+      pop.onclick = function (e) { e.stopPropagation(); openWindow(g); };
+      pop.onkeydown = function (e) {
+        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); openWindow(g); }
+      };
+      list.appendChild(b);
+      buttons[g.id] = b;
+    });
+    var want = location.hash.replace('#', '');
+    if (!want) { try { want = localStorage.getItem('aa-last-sound'); } catch (e) {} }
+    if (!want || !games.some(function (g) { return g.id === want; })) {
+      want = games.length ? games[0].id : null;
+    }
+    if (want) select(want, false);
+  });
+
+  addEventListener('hashchange', function () {
+    var id = location.hash.replace('#', '');
+    if (id) select(id, false);
+  });
+})();
+</script>
+<script>
+/* ---- Onload hero: canvas fireworks / explosion motion graphics ---- */
+(function () {
+  var hero = document.getElementById('hero');
+  if (!hero) return;
+  var cv = document.getElementById('herofx');
+  var ctx = cv.getContext('2d');
+  var W = 0, H = 0, dpr = 1;
+  var COLORS = ['#7c5cff', '#ff4d9d', '#ffd23e', '#3ddc97', '#37c7ff', '#ff7847', '#ffffff'];
+  var rockets = [], parts = [], flash = 0;
+  var running = true, dismissed = false, t0 = performance.now(), spawnAt = 0;
+
+  function resize() {
+    dpr = Math.min(window.devicePixelRatio || 1, 2);
+    W = cv.clientWidth; H = cv.clientHeight;
+    cv.width = Math.floor(W * dpr); cv.height = Math.floor(H * dpr);
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+  }
+  window.addEventListener('resize', resize);
+
+  function rand(a, b) { return a + Math.random() * (b - a); }
+  function pick(a) { return a[(Math.random() * a.length) | 0]; }
+
+  function launchRocket(tx, ty) {
+    var sx = tx != null ? tx : rand(W * 0.15, W * 0.85);
+    var targetY = ty != null ? ty : rand(H * 0.15, H * 0.5);
+    var sy = H + 8;
+    var dur = rand(38, 58);                 // frames to apex
+    rockets.push({
+      x: sx, y: sy, vy: (targetY - sy) / dur, targetY: targetY,
+      col: pick(COLORS), trail: []
+    });
+  }
+
+  function explode(x, y, col, big) {
+    var n = big ? 90 : (34 + (Math.random() * 40 | 0));
+    var base = rand(2.2, 4.6) * (big ? 1.5 : 1);
+    var hueSpread = Math.random() < 0.4;    // some bursts are multi-color
+    for (var i = 0; i < n; i++) {
+      var a = (Math.PI * 2 * i) / n + rand(-0.12, 0.12);
+      var sp = base * rand(0.35, 1);
+      parts.push({
+        x: x, y: y,
+        vx: Math.cos(a) * sp, vy: Math.sin(a) * sp,
+        col: hueSpread ? pick(COLORS) : col,
+        life: 1, decay: rand(0.008, 0.02) * (big ? 0.7 : 1),
+        r: rand(1.4, 2.8), tw: rand(0, Math.PI * 2)
+      });
+    }
+    flash = Math.min(1, flash + (big ? 0.5 : 0.22));
+  }
+
+  function step() {
+    // motion-blur trail: translucent fill instead of clear
+    ctx.globalCompositeOperation = 'source-over';
+    ctx.fillStyle = 'rgba(6,7,12,0.28)';
+    ctx.fillRect(0, 0, W, H);
+    ctx.globalCompositeOperation = 'lighter';
+
+    // rockets
+    for (var i = rockets.length - 1; i >= 0; i--) {
+      var r = rockets[i];
+      r.x += 0; r.y += r.vy; r.vy += 0.02;   // slight decel
+      r.trail.push({ x: r.x, y: r.y }); if (r.trail.length > 6) r.trail.shift();
+      ctx.strokeStyle = r.col; ctx.lineWidth = 2; ctx.beginPath();
+      for (var k = 0; k < r.trail.length; k++) {
+        var p = r.trail[k]; if (k === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
+      }
+      ctx.stroke();
+      ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(r.x, r.y, 2.2, 0, 7); ctx.fill();
+      if (r.y <= r.targetY || r.vy >= 0) { explode(r.x, r.y, r.col, Math.random() < 0.18); rockets.splice(i, 1); }
+    }
+
+    // particles
+    for (var j = parts.length - 1; j >= 0; j--) {
+      var q = parts[j];
+      q.vx *= 0.985; q.vy = q.vy * 0.985 + 0.045; // drag + gravity
+      q.x += q.vx; q.y += q.vy; q.life -= q.decay; q.tw += 0.3;
+      if (q.life <= 0) { parts.splice(j, 1); continue; }
+      var tw = 0.6 + 0.4 * Math.sin(q.tw);
+      ctx.globalAlpha = Math.max(0, q.life) * tw;
+      ctx.fillStyle = q.col;
+      ctx.beginPath(); ctx.arc(q.x, q.y, q.r, 0, 7); ctx.fill();
+    }
+    ctx.globalAlpha = 1;
+
+    // screen flash
+    if (flash > 0) {
+      ctx.globalCompositeOperation = 'source-over';
+      ctx.fillStyle = 'rgba(255,255,255,' + (flash * 0.10) + ')';
+      ctx.fillRect(0, 0, W, H);
+      flash *= 0.9; if (flash < 0.01) flash = 0;
+    }
+  }
+
+  function loop(now) {
+    if (!running) return;
+    var t = now - t0;
+    // cadence of launches, faster during the opening beat
+    if (now > spawnAt) { launchRocket(); spawnAt = now + (t < 2500 ? rand(220, 420) : rand(420, 780)); }
+    step();
+    requestAnimationFrame(loop);
+  }
+
+  function dismiss() {
+    if (dismissed) return; dismissed = true;
+    // celebratory finale
+    for (var i = 0; i < 7; i++) explode(rand(W * 0.15, W * 0.85), rand(H * 0.2, H * 0.6), pick(COLORS), i % 2 === 0);
+    hero.classList.add('hide');
+    setTimeout(function () {
+      running = false;
+      hero.style.display = 'none';
+      // hand keyboard focus back to the game iframe (keyboard-primary games need it)
+      var f = document.getElementById('frame');
+      if (f) { try { f.focus(); if (f.contentWindow) f.contentWindow.focus(); } catch (e) {} }
+    }, 620);
+  }
+
+  hero.addEventListener('click', dismiss);
+  hero.addEventListener('touchstart', function (e) { e.preventDefault(); dismiss(); }, { passive: false });
+  window.addEventListener('keydown', function (e) {
+    if (dismissed) return;
+    if (e.key === 'Enter' || e.key === ' ' || e.code === 'Space' || e.key === 'Escape') { e.preventDefault(); dismiss(); }
+  });
+
+  resize();
+  // reveal the ENTER button after the title lands (JS, so it works even when
+  // CSS animations are suppressed by prefers-reduced-motion / headless)
+  var enterBtn = document.getElementById('heroEnter');
+  if (enterBtn) setTimeout(function () { enterBtn.classList.add('show'); }, 1600);
+  // opening barrage so the splash lands with a bang
+  for (var b = 0; b < 3; b++) setTimeout(launchRocket, b * 180);
+  requestAnimationFrame(loop);
+  // auto-enter after a beat so the splash is never a wall
+  setTimeout(dismiss, 9000);
+})();
+</script>
+</body>
+</html>
diff --git a/sound.json b/sound.json
new file mode 100644
index 0000000..f443934
--- /dev/null
+++ b/sound.json
@@ -0,0 +1,3 @@
+{
+  "sounds": []
+}
diff --git a/sounds/.gitkeep b/sounds/.gitkeep
new file mode 100644
index 0000000..e69de29

(oldest)  ·  back to Sound Agentabrams  ·  SoundLab: footer copy → sounds bd9b72d →