[object Object]

← back to Games Agentabrams

Every game opens in its own window: pop-out controls are now real target=_blank links (Cmd/middle-click + popup-block-safe)

d6891a22cc3c18dea4fab3278462ff3a9aa79fd5 · 2026-07-25 09:28:35 -0700 · Steve

Files touched

Diff

commit d6891a22cc3c18dea4fab3278462ff3a9aa79fd5
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 09:28:35 2026 -0700

    Every game opens in its own window: pop-out controls are now real target=_blank links (Cmd/middle-click + popup-block-safe)
---
 index.html       | 44 ++++++++++++++++++++++++----------
 tests/popout.mjs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 103 insertions(+), 13 deletions(-)

diff --git a/index.html b/index.html
index a2c9df8..8b43318 100644
--- a/index.html
+++ b/index.html
@@ -45,7 +45,7 @@
   .game.active .fdesc { display: block; }
   /* open-in-own-window pop-out button */
   .game .popout {
-    flex: 0 0 auto; align-self: center; cursor: pointer;
+    flex: 0 0 auto; align-self: center; cursor: pointer; text-decoration: none;
     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;
@@ -54,7 +54,7 @@
   .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;
+    position: absolute; top: 12px; right: 12px; z-index: 5; cursor: pointer; text-decoration: none;
     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;
@@ -149,7 +149,7 @@
   <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>
+  <a id="popCurrent" href="#" target="_blank" rel="noopener" title="Open this game in its own window">⧉ own window</a>
   <iframe id="frame" title="game" hidden></iframe>
   <div id="empty"><div class="big">🕹</div><div>select a game</div></div>
 </main>
@@ -161,13 +161,25 @@
   var popCurrent = document.getElementById('popCurrent');
   var games = [], buttons = {}, DEFAULT_ICON = '🎮';
 
-  // open a game in its OWN browser window (not the iframe)
-  function openWindow(g) {
+  // Standalone, shareable URL for a game's own page. nginx serves index.html
+  // for the directory; the explicit index.html also resolves over file://.
+  function gameUrl(g) { return g.path + 'index.html'; }
+
+  // Open a game in its OWN window/tab. The controls are real anchors, so the
+  // href carries the destination: Cmd/Ctrl/Shift/middle-click and right-click
+  // "Open in New Window" are handled natively by the browser. We only intercept
+  // a plain left-click to pop a nicely-sized window — and if the popup blocker
+  // kills that, we DON'T preventDefault, so the anchor's target=_blank still
+  // opens a new tab. "Must open in a new window" holds for every game.
+  function openWindow(g, e) {
     if (!g) return;
+    if (e && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1)) return; // native
     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);
+    var win = window.open(gameUrl(g), 'aa_' + g.id, feat);
+    if (win) { if (e) e.preventDefault(); try { win.focus(); } catch (_) {} }
+    // else popup blocked -> let the anchor navigate to a new tab
   }
 
   function select(id, push) {
@@ -176,7 +188,8 @@
     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); };
+    popCurrent.href = gameUrl(g);
+    popCurrent.onclick = function (e) { openWindow(g, e); };
     // 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) {} };
@@ -190,20 +203,25 @@
   function render(data) {
     games = data.games || [];
     games.forEach(function (g) {
-      var b = document.createElement('button');
+      // row is a div[role=button] (not <button>) so it can legally host a
+      // nested <a> pop-out — a <button> may not contain interactive content.
+      var b = document.createElement('div');
       b.className = 'game';
+      b.setAttribute('role', 'button');
+      b.tabIndex = 0;
       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>';
+                    '<a class="popout" target="_blank" rel="noopener" title="Open in its own window">⧉</a>';
       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); }
+      b.onkeydown = function (e) {
+        if (e.target === b && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(g.id); }
       };
+      var pop = b.querySelector('.popout');
+      pop.href = gameUrl(g);                       // real href -> native new-tab gestures work
+      pop.onclick = function (e) { e.stopPropagation(); openWindow(g, e); };
       list.appendChild(b);
       buttons[g.id] = b;
     });
diff --git a/tests/popout.mjs b/tests/popout.mjs
new file mode 100644
index 0000000..fe2a533
--- /dev/null
+++ b/tests/popout.mjs
@@ -0,0 +1,72 @@
+// Verify every game is "openable in its own window": each row exposes a real
+// <a> pop-out with the correct href, the stage pop-out is a real link, and a
+// plain click actually spawns a new browser window/tab. Focuses on rope-physics
+// but checks all games have a valid, resolvable pop-out href.
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+import path from 'path';
+import http from 'http';
+import fs from 'fs';
+
+const require = createRequire(import.meta.url);
+let chromium;
+try { ({ chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright')); }
+catch { ({ chromium } = require('playwright')); }
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const MIME = { '.html': 'text/html', '.json': 'application/json', '.png': 'image/png', '.js': 'text/javascript' };
+const server = http.createServer((req, res) => {
+  let p = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
+  if (p.endsWith('/')) p += 'index.html';
+  const f = path.join(root, p);
+  if (!f.startsWith(root) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); res.end('nf'); return; }
+  res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'text/plain' });
+  fs.createReadStream(f).pipe(res);
+});
+await new Promise(r => server.listen(0, '127.0.0.1', r));
+const base = `http://127.0.0.1:${server.address().port}/`;
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext();
+const page = await ctx.newPage();
+let fail = 0;
+const ok = (c, m) => { console.log((c ? 'PASS' : 'FAIL') + ' — ' + m); if (!c) fail++; };
+
+await page.goto(base + '#rope-physics', { waitUntil: 'networkidle' });
+await page.waitForSelector('.game .popout');
+// dismiss the fireworks hero splash (z-index 9999) so it can't intercept clicks
+await page.keyboard.press('Enter');
+await page.waitForSelector('#hero', { state: 'hidden', timeout: 5000 }).catch(() => {});
+
+// 1. every row pop-out is an anchor with an href that resolves (HTTP 200)
+const hrefs = await page.$$eval('.game .popout', els => els.map(a => ({ tag: a.tagName, href: a.getAttribute('href'), target: a.getAttribute('target') })));
+ok(hrefs.length >= 30, `found ${hrefs.length} game pop-out links`);
+ok(hrefs.every(h => h.tag === 'A'), 'every pop-out is an <a> element');
+ok(hrefs.every(h => h.target === '_blank'), 'every pop-out has target=_blank');
+let bad = 0;
+for (const h of hrefs) {
+  const r = await ctx.request.get(new URL(h.href, base).href);
+  if (r.status() !== 200) { bad++; console.log('   broken href:', h.href, r.status()); }
+}
+ok(bad === 0, `all ${hrefs.length} pop-out targets return HTTP 200`);
+
+// 2. stage "own window" control is an anchor pointing at the selected game
+const cur = await page.$eval('#popCurrent', a => ({ tag: a.tagName, href: a.getAttribute('href'), target: a.getAttribute('target') }));
+ok(cur.tag === 'A' && cur.target === '_blank', 'stage pop-out is an <a target=_blank>');
+ok(/games\/rope-physics\/index\.html$/.test(cur.href), 'stage pop-out points at rope-physics: ' + cur.href);
+
+// 3. a plain click on the stage pop-out actually opens a new window
+const [popup] = await Promise.all([
+  ctx.waitForEvent('page', { timeout: 4000 }).catch(() => null),
+  page.click('#popCurrent'),
+]);
+ok(!!popup, 'clicking "own window" opened a new browser window');
+if (popup) {
+  await popup.waitForLoadState('domcontentloaded').catch(() => {});
+  ok(/rope-physics/.test(popup.url()), 'new window loaded the rope-physics game: ' + popup.url());
+}
+
+await browser.close();
+server.close();
+console.log(fail ? `\n${fail} FAILURE(S)` : '\nALL GREEN');
+process.exit(fail ? 1 : 0);

← 10428c6 Add Beverly Hills Slots (PinkPalaceSlots.aa) — source-backed  ·  back to Games Agentabrams  ·  tests: add motion regression test — proves all 19 action gam 8b233d3 →