fuzzyworld

dm / commit

feat: add Report Issue button with auto-collected debug info

83889109f219098a5ad8749def84d7071ebcae32 ยท Pavel Safronov ยท 2026-05-27 17:59:39 -0700 ยท parents 343826a9d262

feat: add Report Issue button with auto-collected debug info

Adds a ๐Ÿ› Report button to the game header that opens a modal with:
- Optional free-text description field
- Auto-collected debug info (app state, character, session cost,
  player/game IDs, browser UA, recent console errors)
- Three submission paths: email to Service Desk (no account needed),
  open pre-filled GitLab issue, or copy to clipboard

Console errors are intercepted into a rolling 30-entry buffer so
they appear automatically in the debug info when something goes wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 public/index.html | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 181 insertions(+), 1 deletion(-)

diff --git a/public/index.html b/public/index.html
index 94e0a64..9c0eded 100644
--- a/public/index.html
+++ b/public/index.html
@@ -823,6 +823,63 @@ body {
 }
 .share-link-trigger:hover { border-color: var(--gold-dim); color: var(--gold); }
 
+/* โ”€โ”€ REPORT MODAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
+
+#report-modal {
+  display: none;
+  position: fixed;
+  inset: 0;
+  z-index: 200;
+  background: rgba(0,0,0,0.75);
+  align-items: center;
+  justify-content: center;
+}
+#report-modal.open { display: flex; }
+
+.report-inner {
+  background: linear-gradient(135deg, #0e0e1a, #141420);
+  border: 1px solid rgba(107,79,187,0.4);
+  border-radius: 10px;
+  padding: 28px 32px;
+  width: 520px;
+  max-width: 95vw;
+  max-height: 88vh;
+  overflow-y: auto;
+  box-shadow: 0 0 60px rgba(107,79,187,0.15), 0 20px 60px rgba(0,0,0,0.8);
+}
+
+.report-title {
+  font-family: 'Cinzel', serif;
+  font-size: 1.1rem;
+  font-weight: 600;
+  color: var(--text-bright);
+  margin-bottom: 20px;
+  letter-spacing: 0.06em;
+}
+
+.report-debug-box {
+  font-family: monospace;
+  font-size: 0.7rem;
+  line-height: 1.5;
+  background: var(--bg-input);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 10px 12px;
+  color: var(--text-dim);
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 180px;
+  overflow-y: auto;
+  margin-bottom: 16px;
+}
+
+.report-actions {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-top: 4px;
+}
+
 /* access code row */
 .access-code-label {
   font-family: 'Cinzel', serif;
@@ -924,7 +981,10 @@ body {
   <div class="chat-panel">
     <div class="chat-header">
       <div class="chat-header-title" id="header-title">Solo Dungeon Master</div>
-      <button class="new-game-btn" id="new-game-btn">โœฆ New Game</button>
+      <div style="display:flex;gap:8px">
+        <button class="new-game-btn" id="report-btn">๐Ÿ› Report</button>
+        <button class="new-game-btn" id="new-game-btn">โœฆ New Game</button>
+      </div>
     </div>
 
     <div id="messages"></div>
@@ -1051,6 +1111,32 @@ body {
   </div>
 </div>
 
+<!-- Report Issue modal -->
+<div id="report-modal">
+  <div class="report-inner">
+    <div class="report-title">๐Ÿ› Report an Issue</div>
+
+    <div class="field-group" style="margin-bottom:14px">
+      <label>What went wrong? <span style="color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">(optional โ€” describe what happened)</span></label>
+      <textarea id="report-desc" rows="3" placeholder="e.g. The game froze after I chose option 2โ€ฆ" autocomplete="off"
+        style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none;resize:vertical;margin-top:6px"></textarea>
+    </div>
+
+    <div style="font-family:'Cinzel',serif;font-size:0.65rem;letter-spacing:0.12em;color:var(--text-dim);text-transform:uppercase;margin-bottom:6px">Debug info (included automatically)</div>
+    <div class="report-debug-box" id="report-debug-box"></div>
+
+    <div class="report-actions">
+      <button class="share-btn share-btn-primary" id="report-email-btn">๐Ÿ“ง Email โ€” no account needed</button>
+      <button class="share-btn share-btn-secondary" id="report-gitlab-btn">๐Ÿ”— GitLab Issue</button>
+      <button class="share-btn share-btn-secondary" id="report-copy-btn">๐Ÿ“‹ Copy info</button>
+      <button class="share-btn share-btn-secondary" id="report-close-btn">Cancel</button>
+    </div>
+    <p style="font-size:0.72rem;color:var(--text-dim);margin-top:10px;font-style:italic">
+      Email creates a support ticket with no account required. GitLab Issue requires a free GitLab account.
+    </p>
+  </div>
+</div>
+
 <!-- Level up toast -->
 <div id="levelup-toast">
   <div class="toast-icon">โญ</div>
@@ -1060,6 +1146,19 @@ body {
 </div>
 
 <script>
+// โ”€โ”€ ERROR BUFFER โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// Capture recent console.error calls so they can be included in bug reports.
+const _recentErrors = [];
+{
+  const _orig = console.error.bind(console);
+  console.error = (...args) => {
+    _orig(...args);
+    const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+    _recentErrors.push({ t: new Date().toISOString(), msg });
+    if (_recentErrors.length > 30) _recentErrors.shift();
+  };
+}
+
 // โ”€โ”€ CRYPTO โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 // Two implementations: AES-GCM (requires secure context) and a pure-JS
 // XOR stream cipher fallback for plain HTTP.  Blobs are prefixed with
@@ -1312,6 +1411,37 @@ function updateCostDisplay() {
   document.getElementById('cost-total').textContent = fmt(sessionCost);
 }
 
+// โ”€โ”€ BUG REPORT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+function buildDebugInfo(userDesc) {
+  const lines = [];
+  if (userDesc) { lines.push(`User description:\n${userDesc}`, ''); }
+  lines.push('--- App ---');
+  lines.push(`Time: ${new Date().toISOString()}`);
+  lines.push(`URL: ${location.origin}${location.pathname}`);
+  lines.push(`Secure context: ${window.isSecureContext}`);
+
+  if (gameState) {
+    const c = gameState.character;
+    lines.push('', '--- Session ---');
+    lines.push(`Player ID: p_${getOrCreatePlayerId()}`);
+    if (gameState.gameId) lines.push(`Game ID:   g_${gameState.gameId}`);
+    lines.push(`Character: ${c.name} โ€” Lv ${c.level} ${c.race} ${c.class}`);
+    lines.push(`HP: ${c.hp}/${c.maxHp}  XP: ${c.xp}  Gold: ${c.gold}g`);
+    lines.push(`Turns: ${conversationHistory.length}  Cost: $${sessionCost.toFixed(5)}`);
+  }
+
+  lines.push('', '--- Browser ---');
+  lines.push(navigator.userAgent);
+
+  if (_recentErrors.length > 0) {
+    lines.push('', '--- Recent errors ---');
+    _recentErrors.forEach(e => lines.push(`[${e.t}]\n${e.msg}`));
+  }
+
+  return lines.join('\n');
+}
+
 // โ”€โ”€ SESSION PERSISTENCE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 function saveSession() {
@@ -2038,6 +2168,56 @@ document.getElementById('new-game-btn').addEventListener('click', () => {
   checkBeginReady();
 });
 
+// โ”€โ”€ REPORT MODAL HANDLERS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+const SERVICE_DESK_EMAIL = 'contact-project+pavelsafronov-dm-82624720-issue-@incoming.gitlab.com';
+const GITLAB_ISSUES_URL  = 'https://gitlab.com/PavelSafronov/dm/-/issues/new';
+
+function openReportModal() {
+  document.getElementById('report-desc').value = '';
+  document.getElementById('report-debug-box').textContent = buildDebugInfo('');
+  document.getElementById('report-modal').classList.add('open');
+}
+
+document.getElementById('report-btn').addEventListener('click', openReportModal);
+
+document.getElementById('report-desc').addEventListener('input', () => {
+  document.getElementById('report-debug-box').textContent =
+    buildDebugInfo(document.getElementById('report-desc').value.trim());
+});
+
+document.getElementById('report-close-btn').addEventListener('click', () => {
+  document.getElementById('report-modal').classList.remove('open');
+});
+document.getElementById('report-modal').addEventListener('click', e => {
+  if (e.target === document.getElementById('report-modal'))
+    document.getElementById('report-modal').classList.remove('open');
+});
+
+document.getElementById('report-email-btn').addEventListener('click', () => {
+  const desc  = document.getElementById('report-desc').value.trim();
+  const body  = buildDebugInfo(desc);
+  const title = desc ? desc.split('\n')[0].slice(0, 80) : 'Bug report: Solo Dungeon Master';
+  window.open(`mailto:${SERVICE_DESK_EMAIL}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`);
+});
+
+document.getElementById('report-gitlab-btn').addEventListener('click', () => {
+  const desc  = document.getElementById('report-desc').value.trim();
+  const body  = buildDebugInfo(desc);
+  const title = desc ? desc.split('\n')[0].slice(0, 80) : 'Bug report';
+  const params = new URLSearchParams({ 'issue[title]': title, 'issue[description]': body });
+  window.open(`${GITLAB_ISSUES_URL}?${params}`);
+});
+
+document.getElementById('report-copy-btn').addEventListener('click', () => {
+  const desc = document.getElementById('report-desc').value.trim();
+  navigator.clipboard.writeText(buildDebugInfo(desc)).then(() => {
+    const btn = document.getElementById('report-copy-btn');
+    btn.textContent = 'โœ“ Copied!';
+    setTimeout(() => { btn.textContent = '๐Ÿ“‹ Copy info'; }, 2000);
+  });
+});
+
 // โ”€โ”€ INIT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 (async () => {