[object Object]

← back to Domain Sniper

step 10: dashboard UX — sort tabs, click-to-copy reg cmd, audio ping on BRAND

109b3c14ea8d0909c10845db975fdd26f1569a19 · 2026-05-12 15:31:57 -0700 · Steve Abrams

Sort: time (default) / brand-first / hot-first, persisted in localStorage.
Click any row to copy 'node register.js <domain>' to clipboard with toast.
WebAudio ping (880 Hz, 220ms) on BRAND hits; toggle persisted.
README logs ~2h honeypot spot-check (10/10 baits still available).

Files touched

Diff

commit 109b3c14ea8d0909c10845db975fdd26f1569a19
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 15:31:57 2026 -0700

    step 10: dashboard UX — sort tabs, click-to-copy reg cmd, audio ping on BRAND
    
    Sort: time (default) / brand-first / hot-first, persisted in localStorage.
    Click any row to copy 'node register.js <domain>' to clipboard with toast.
    WebAudio ping (880 Hz, 220ms) on BRAND hits; toggle persisted.
    README logs ~2h honeypot spot-check (10/10 baits still available).
---
 README.md         |  13 +++-
 public/index.html | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 186 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index b3ab1d7..52bcffb 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,18 @@ We can't get the WHOIS query stream (it's gated to aggregator partners), but we
   - Direct `curl` against rdap.verisign.com / rdap.nic.google / api.godaddy.com `/v1/domains/available` / Namecheap `domains.check`
   - Backup: `~/.claude/settings.json.bak-2026-05-12-yolo-tick2`
   - Does NOT affect domain-sniper scripts (honeypot.js, check.js) — those use Node's child_process / https directly and aren't routed through Claude's Bash permission layer.
-- [ ] **Step 10** — extend dashboard with sort, click-to-register, sound alerts on brand hits.
+- [x] **Step 10** — dashboard UX extension: sort tabs (time / brand-first / hot-first), click-any-row to copy `node register.js <domain>` to clipboard, audible BRAND ping via WebAudio, toast confirmation. All settings persist in localStorage (`sniper.sort`, `sniper.audio`, etc.).
+
+## Snipe-timing curve (honeypot 2026-05-12T20:36 UTC)
+
+Spot-checks via DoH at increasing intervals to find where the snipe window closes. Datapoints logged to `data/honeypot-spotcheck.jsonl`.
+
+| Elapsed | Buckets checked | Snipes |
+| --- | --- | --- |
+| ~2h    | A/B/C/D/E (2 ea) | 0 / 10 |
+| ~6h    | _pending_        | — |
+| ~24h   | full batch       | — |
+| ~48h   | full batch (final) | — |
 
 ## YOLO loop notes
 
diff --git a/public/index.html b/public/index.html
index d68d3bb..50abeaa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -36,7 +36,7 @@
     .ctrl {
       padding: 12px 20px;
       border-bottom: 1px solid #1f1f1f;
-      display: flex; gap: 12px; align-items: center;
+      display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
     }
     .ctrl input[type="text"] {
       background: #161616; border: 1px solid #2a2a2a; color: var(--fg);
@@ -44,6 +44,14 @@
     }
     .ctrl label { display: flex; align-items: center; gap: 6px; cursor: pointer; color: var(--dim); font-size: 12px; }
     .ctrl label input { accent-color: var(--accent); }
+    .ctrl .sortgrp { display: flex; gap: 0; border: 1px solid #2a2a2a; border-radius: 4px; overflow: hidden; }
+    .ctrl .sortgrp button {
+      background: #161616; color: var(--dim); border: none; padding: 5px 10px;
+      font-family: inherit; font-size: 11px; cursor: pointer; border-right: 1px solid #2a2a2a;
+    }
+    .ctrl .sortgrp button:last-child { border-right: none; }
+    .ctrl .sortgrp button.active { background: #1f1f1f; color: var(--accent); }
+    .ctrl .sortgrp button:hover:not(.active) { color: var(--fg); }
     .ctrl .chip {
       padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600;
       letter-spacing: 0.5px; text-transform: uppercase;
@@ -62,27 +70,39 @@
       align-items: center;
       gap: 16px;
       transition: background 0.1s;
+      cursor: pointer;
     }
     .row:hover { background: var(--row-hover); }
     .row.hot { background: rgba(255, 180, 84, 0.04); }
     .row.brand { background: rgba(255, 59, 59, 0.06); border-left: 3px solid var(--brand); padding-left: 17px; }
+    .row.flash { animation: flash 0.4s ease-out; }
+    @keyframes flash { 0% { background: rgba(78, 201, 176, 0.25); } 100% {} }
     .row .t { color: var(--dim); font-size: 11px; }
     .row .tag { font-size: 10px; font-weight: 600; letter-spacing: 0.5px; }
     .row.hot .tag { color: var(--hot); }
     .row.brand .tag { color: var(--brand); }
     .row .domain { font-weight: 500; color: var(--fg); }
     .row .actions { display: flex; gap: 8px; justify-content: flex-end; }
-    .row .actions a {
+    .row .actions a, .row .actions button {
       color: var(--dim); text-decoration: none; font-size: 11px;
       border: 1px solid #2a2a2a; padding: 3px 8px; border-radius: 3px;
+      background: transparent; font-family: inherit; cursor: pointer;
     }
-    .row .actions a:hover { color: var(--accent); border-color: var(--accent); }
+    .row .actions a:hover, .row .actions button:hover { color: var(--accent); border-color: var(--accent); }
     .empty { padding: 40px; color: var(--dim); text-align: center; }
     .note {
       padding: 12px 20px; background: #141414; color: var(--dim); font-size: 11px;
       border-bottom: 1px solid #1f1f1f; line-height: 1.5;
     }
     .note b { color: var(--fg); }
+    .toast {
+      position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
+      background: #1f1f1f; color: var(--accent); border: 1px solid var(--accent);
+      padding: 10px 16px; border-radius: 4px; font-size: 12px;
+      opacity: 0; transition: opacity 0.2s; pointer-events: none;
+      max-width: 80vw; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+    }
+    .toast.show { opacity: 1; }
   </style>
 </head>
 <body>
@@ -97,18 +117,26 @@
     </div>
   </header>
   <div class="note">
-    Data source — <b>Certificate Transparency logs</b> via CertStream. Every TLS cert issued worldwide flows through here, ~1 min after a domain gets registered. This is the closest free public real-time analog to a registrar's check-stream. Rows marked <span class="chip brand">BRAND</span> match one of your watched keywords. Rows marked <span class="chip hot">HOT</span> fit the short-pronounceable-mainstream-TLD pattern aggregators target.
+    Data source — <b>Certificate Transparency logs</b> via CertStream. Every TLS cert issued worldwide flows through here, ~1 min after a domain gets registered. This is the closest free public real-time analog to a registrar's check-stream. Rows marked <span class="chip brand">BRAND</span> match one of your watched keywords. Rows marked <span class="chip hot">HOT</span> fit the short-pronounceable-mainstream-TLD pattern aggregators target. <b>Click any row to copy a <code>node register.js &lt;domain&gt;</code> command to the clipboard.</b>
   </div>
   <div class="ctrl">
     <input id="filter" type="text" placeholder="filter (substring match)" autocomplete="off" />
     <label><input type="checkbox" id="show-hot" checked /> hot</label>
     <label><input type="checkbox" id="show-brand" checked /> brand</label>
     <label><input type="checkbox" id="autoscroll" checked /> auto-scroll</label>
+    <label><input type="checkbox" id="audio" checked /> audio on brand</label>
+    <span style="color: var(--dim); font-size: 11px;">sort:</span>
+    <div class="sortgrp" id="sortgrp">
+      <button data-sort="time" class="active">time</button>
+      <button data-sort="brand">brand-first</button>
+      <button data-sort="hot">hot-first</button>
+    </div>
     <span style="margin-left:auto; color: var(--dim); font-size: 11px;">
       <span id="conn">connecting…</span>
     </span>
   </div>
   <main id="feed"><div class="empty">waiting for the firehose…</div></main>
+  <div id="toast" class="toast">copied</div>
 
   <script>
     const feed = document.getElementById('feed');
@@ -117,6 +145,9 @@
     const showHot = document.getElementById('show-hot');
     const showBrand = document.getElementById('show-brand');
     const autoscroll = document.getElementById('autoscroll');
+    const audioEl = document.getElementById('audio');
+    const toast = document.getElementById('toast');
+    const sortgrp = document.getElementById('sortgrp');
     const stat = {
       seen: document.getElementById('stat-seen'),
       hot: document.getElementById('stat-hot'),
@@ -127,7 +158,23 @@
     let lastSeen = 0;
     let lastTs = Date.now();
     let startedAt = Date.now();
-    let isEmpty = true;
+    const hits = []; // newest-first
+    const MAX_HITS = 500;
+    let sortMode = localStorage.getItem('sniper.sort') || 'time';
+
+    // Persist filter/checkbox state too
+    for (const [el, key, attr] of [
+      [filterEl, 'sniper.filter', 'value'],
+      [showHot, 'sniper.showHot', 'checked'],
+      [showBrand, 'sniper.showBrand', 'checked'],
+      [autoscroll, 'sniper.autoscroll', 'checked'],
+      [audioEl, 'sniper.audio', 'checked'],
+    ]) {
+      const v = localStorage.getItem(key);
+      if (v !== null) el[attr] = attr === 'checked' ? v === '1' : v;
+      el.addEventListener('change', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
+      el.addEventListener('input', () => localStorage.setItem(key, attr === 'checked' ? (el[attr] ? '1' : '0') : el[attr]));
+    }
 
     function fmt(ts) {
       const d = new Date(ts);
@@ -143,31 +190,145 @@
     function shouldShow(rec) {
       if (rec.brand && !showBrand.checked) return false;
       if (rec.hot && !rec.brand && !showHot.checked) return false;
+      if (!rec.hot && !rec.brand) return false;
       const q = filterEl.value.trim().toLowerCase();
       if (q && !rec.domain.toLowerCase().includes(q)) return false;
       return true;
     }
-    function addRow(rec) {
-      if (isEmpty) { feed.innerHTML = ''; isEmpty = false; }
-      if (!shouldShow(rec)) return;
+    function sortedHits() {
+      if (sortMode === 'time') return hits;
+      if (sortMode === 'brand') return [...hits].sort((a, b) => {
+        const ab = a.brand ? 1 : 0, bb = b.brand ? 1 : 0;
+        if (ab !== bb) return bb - ab;
+        return b.ts - a.ts;
+      });
+      if (sortMode === 'hot') return [...hits].sort((a, b) => {
+        const ah = a.hot ? 1 : 0, bh = b.hot ? 1 : 0;
+        if (ah !== bh) return bh - ah;
+        return b.ts - a.ts;
+      });
+      return hits;
+    }
+
+    let toastTimer = null;
+    function showToast(msg) {
+      toast.textContent = msg;
+      toast.classList.add('show');
+      clearTimeout(toastTimer);
+      toastTimer = setTimeout(() => toast.classList.remove('show'), 1600);
+    }
+
+    async function copyRegisterCmd(domain) {
+      const cmd = 'node register.js ' + domain;
+      try {
+        await navigator.clipboard.writeText(cmd);
+        showToast('copied: ' + cmd);
+      } catch {
+        // Fallback if clipboard API blocked (insecure context)
+        const ta = document.createElement('textarea');
+        ta.value = cmd; document.body.appendChild(ta); ta.select();
+        try { document.execCommand('copy'); showToast('copied (fallback): ' + cmd); }
+        catch { showToast('copy failed — ' + cmd); }
+        ta.remove();
+      }
+    }
+
+    let audioCtx = null;
+    function ping() {
+      if (!audioEl.checked) return;
+      try {
+        audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
+        const o = audioCtx.createOscillator();
+        const g = audioCtx.createGain();
+        o.type = 'sine'; o.frequency.value = 880;
+        g.gain.value = 0.0001;
+        o.connect(g); g.connect(audioCtx.destination);
+        const now = audioCtx.currentTime;
+        g.gain.exponentialRampToValueAtTime(0.18, now + 0.01);
+        g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
+        o.start(now); o.stop(now + 0.24);
+      } catch {}
+    }
+
+    function buildRow(rec) {
       const cls = rec.brand ? 'brand' : (rec.hot ? 'hot' : '');
       const tag = rec.brand ? `BRAND · ${rec.brand}` : (rec.hot ? 'HOT' : '');
       const row = document.createElement('div');
       row.className = 'row ' + cls;
+      row.dataset.domain = rec.domain;
       row.innerHTML = `
         <span class="t">${fmt(rec.ts)}</span>
         <span class="tag">${tag}</span>
         <span class="domain">${rec.domain}</span>
         <span class="actions">
+          <button data-act="copy">copy reg cmd</button>
           <a href="https://${rec.domain}" target="_blank" rel="noopener">visit</a>
           <a href="https://crt.sh/?q=${encodeURIComponent(rec.domain)}" target="_blank" rel="noopener">crt.sh</a>
         </span>
       `;
-      feed.prepend(row);
-      while (feed.children.length > 500) feed.removeChild(feed.lastChild);
-      if (autoscroll.checked) feed.scrollTop = 0;
+      return row;
+    }
+
+    function render() {
+      const list = sortedHits().filter(shouldShow).slice(0, MAX_HITS);
+      if (!list.length) {
+        feed.innerHTML = '<div class="empty">waiting for matches…</div>';
+        return;
+      }
+      feed.innerHTML = '';
+      for (const rec of list) feed.appendChild(buildRow(rec));
+      if (sortMode === 'time' && autoscroll.checked) feed.scrollTop = 0;
+    }
+
+    function onHit(rec) {
+      hits.unshift(rec);
+      while (hits.length > MAX_HITS * 2) hits.pop();
+      if (rec.brand) ping();
+      if (sortMode === 'time') {
+        // Cheap path: just prepend/flash without re-rendering everything
+        if (!shouldShow(rec)) return;
+        if (feed.querySelector('.empty')) feed.innerHTML = '';
+        const row = buildRow(rec);
+        row.classList.add('flash');
+        feed.prepend(row);
+        while (feed.children.length > MAX_HITS) feed.removeChild(feed.lastChild);
+        if (autoscroll.checked) feed.scrollTop = 0;
+      } else {
+        // Re-sort throttled
+        scheduleRender();
+      }
     }
 
+    let renderPending = false;
+    function scheduleRender() {
+      if (renderPending) return;
+      renderPending = true;
+      requestAnimationFrame(() => { renderPending = false; render(); });
+    }
+
+    // Row click — copy reg cmd. Don't hijack clicks on the <a> links.
+    feed.addEventListener('click', (e) => {
+      const row = e.target.closest('.row');
+      if (!row) return;
+      if (e.target.tagName === 'A') return;
+      const dom = row.dataset.domain;
+      if (dom) copyRegisterCmd(dom);
+    });
+
+    // Sort buttons
+    sortgrp.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-sort]');
+      if (!btn) return;
+      sortMode = btn.dataset.sort;
+      localStorage.setItem('sniper.sort', sortMode);
+      for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b === btn);
+      render();
+    });
+    for (const b of sortgrp.querySelectorAll('button')) b.classList.toggle('active', b.dataset.sort === sortMode);
+
+    for (const el of [filterEl, showHot, showBrand]) el.addEventListener('input', scheduleRender);
+    for (const el of [filterEl, showHot, showBrand]) el.addEventListener('change', scheduleRender);
+
     function connect() {
       const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
       const ws = new WebSocket(proto + '//' + location.host);
@@ -176,12 +337,12 @@
       ws.onerror = () => {};
       ws.onmessage = (ev) => {
         let m; try { m = JSON.parse(ev.data); } catch { return; }
-        if (m.type === 'hit') addRow(m);
+        if (m.type === 'hit') onHit(m);
         else if (m.type === 'stats') {
           const now = Date.now();
           const dt = (now - lastTs) / 1000;
           const dseen = m.seen - lastSeen;
-          stat.rate.textContent = dt > 0 ? Math.round(dseen / dt) : 0;
+          stat.rate.textContent = dt > 0 ? Math.max(0, Math.round(dseen / dt)) : 0;
           lastSeen = m.seen; lastTs = now;
           stat.seen.textContent = m.seen.toLocaleString();
           stat.hot.textContent = m.hot.toLocaleString();

← dc2abc5 step 7: brand-typo-watcher.js — Levenshtein + Punycode brand  ·  back to Domain Sniper  ·  note: certstream.calidog.io connect-close loop (code 1000, 0 6760b8c →