# Bug Reporting Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a "Report a Bug" footer link that opens the Bugpin screenshot widget (forwards to GitHub Issues), plus auto error capture via Bugsink (Sentry-compatible) on every page.

**Architecture:** Two independent flows. Bugpin = user-initiated, lazy-loaded `<script>` widget from `bugpin.half.st`, triggered by a footer `<button>`. Bugsink = background, `@sentry/browser` SDK initialised on page load against a DSN at `bugsink.half.st`. Both credentials are public project tokens and live in `src/world/ui.md`. Both flows are no-ops when not configured.

**Tech Stack:** Astro 6, TypeScript, Zod 4, Vitest 4 (node env, no jsdom), `@sentry/browser` (new dep), Bugpin widget script.

**Spec:** `docs/superpowers/specs/2026-05-17-bug-reporting-design.md`

---

## File map

- `src/world/schema.ts` — **modify**: extend `uiFrontmatterSchema` with optional `bugReport` block.
- `src/world/schema.test.ts` — **modify**: add accept/reject cases for `bugReport`.
- `src/world/types.ts` — **modify**: extend `UiConfig` to expose `bugReport`.
- `src/world/ui.md` — **modify**: add `bugReport` frontmatter.
- `src/ui/error-tracking.ts` — **create**: reads body data attrs, calls `Sentry.init`. No-op when DSN missing.
- `src/ui/bug-report.ts` — **create**: wires footer button click to lazy-load Bugpin widget, then call `BugPin.open()`. No-op when config missing.
- `src/pages/index.astro` — **modify**: render the footer button conditionally, attach data attrs to `<body>`, import the two new modules.
- `package.json` / `package-lock.json` — **modify**: add `@sentry/browser`.
- `src/world/TODOs.md:45` — **modify**: rewrite to reflect actual approach.

No DOM tests for the two new UI modules: the project's Vitest config runs in node with no jsdom. Coverage for those modules is `astro check` (type safety) + a manual smoke step at the end of the plan. Schema changes get full unit test coverage via the existing test file.

---

## Task 1: Extend `uiFrontmatterSchema` with `bugReport` (TDD)

**Files:**
- Modify: `src/world/schema.ts`
- Modify: `src/world/schema.test.ts`

- [ ] **Step 1: Add the failing tests**

Append these inside the existing `describe('uiFrontmatterSchema', ...)` block in `src/world/schema.test.ts` (just before the closing `})` on line 120):

```ts
  it('accepts a bugReport block with bugpin and bugsink subconfigs', () => {
    const data = {
      pageTitle: 'Halfstreet',
      description: 'A gothic mystery.',
      footer: {
        copyright: '© 2026 Ethan J Lewis',
        links: [],
      },
      bugReport: {
        enabled: true,
        label: 'Report a Bug',
        bugpin: {
          serverUrl: 'https://bugpin.half.st',
          apiKey: 'proj_07df4bf91f12445b8ef8c723e865ed7b',
        },
        bugsink: {
          enabled: true,
          dsn: 'https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1',
        },
      },
    }
    expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
  })

  it('accepts ui config with no bugReport block at all', () => {
    const data = {
      pageTitle: 'Halfstreet',
      description: 'A gothic mystery.',
      footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
    }
    expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
  })

  it('rejects a bugpin block with a non-url serverUrl', () => {
    const data = {
      pageTitle: 'Halfstreet',
      description: 'A gothic mystery.',
      footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
      bugReport: {
        enabled: true,
        bugpin: { serverUrl: 'not-a-url', apiKey: 'proj_x' },
      },
    }
    expect(() => uiFrontmatterSchema.parse(data)).toThrow()
  })

  it('rejects a bugsink block with a non-url dsn', () => {
    const data = {
      pageTitle: 'Halfstreet',
      description: 'A gothic mystery.',
      footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
      bugReport: {
        enabled: true,
        bugsink: { dsn: 'whatever' },
      },
    }
    expect(() => uiFrontmatterSchema.parse(data)).toThrow()
  })
```

- [ ] **Step 2: Run tests, confirm they fail**

```
npm test -- src/world/schema.test.ts
```

Expected: the four new tests fail because `bugReport` is currently unknown (zod by default strips extras, so the "accepts" cases probably pass — but the "rejects" cases will fail because zod will accept anything for unknown fields). Either way, confirm the test runner reports at least the two "rejects" cases as failing.

- [ ] **Step 3: Add the schema fields**

In `src/world/schema.ts`, find `uiFrontmatterSchema` (starts at line 48). Add a `bugReport` field at the end of the object (after `features`, before the closing `})`). Final block:

```ts
export const uiFrontmatterSchema = z.object({
  pageTitle: z.string().trim().min(1),
  description: z.string().trim().min(1),
  robots: z.string().trim().min(1).default('noindex'),
  themeColor: z.string().trim().min(1).default('#1a0d00'),
  footer: z.object({
    copyright: z.string().trim().min(1),
    copyrightHref: z.url().optional(),
    buildLabel: z.string().trim().min(1).default('Build #'),
    showBuild: z.boolean().default(true),
    links: z.array(z.object({
      label: z.string().trim().min(1),
      href: z.url(),
    })).default([]),
  }),
  features: z.object({
    chips: z.boolean().default(true),
    lightMeter: z.boolean().default(true),
    typedEffect: z.boolean().default(true),
    roomScroll: z.boolean().default(true),
  }).default({
    chips: true,
    lightMeter: true,
    typedEffect: true,
    roomScroll: true,
  }),
  bugReport: z.object({
    enabled: z.boolean().default(false),
    label: z.string().trim().min(1).default('Report a Bug'),
    bugpin: z.object({
      serverUrl: z.url(),
      apiKey: z.string().trim().min(1),
    }).optional(),
    bugsink: z.object({
      enabled: z.boolean().default(true),
      dsn: z.url(),
    }).optional(),
  }).optional(),
})
```

- [ ] **Step 4: Run tests, confirm pass**

```
npm test -- src/world/schema.test.ts
```

Expected: all `uiFrontmatterSchema` tests pass (the original two + the four new ones).

- [ ] **Step 5: Commit**

```
git add src/world/schema.ts src/world/schema.test.ts
git commit -m "feat(world): add bugReport block to ui schema"
```

---

## Task 2: Surface `bugReport` on the `UiConfig` type

**Files:**
- Modify: `src/world/types.ts:64-85`

- [ ] **Step 1: Extend the interface**

In `src/world/types.ts`, replace the existing `UiConfig` interface (currently lines 64–85) with:

```ts
export interface UiConfig {
  pageTitle: string
  description: string
  robots: string
  themeColor: string
  footer: {
    copyright: string
    copyrightHref?: string
    buildLabel: string
    showBuild: boolean
    links: Array<{
      label: string
      href: string
    }>
  }
  features: {
    chips: boolean
    lightMeter: boolean
    typedEffect: boolean
    roomScroll: boolean
  }
  bugReport?: {
    enabled: boolean
    label: string
    bugpin?: {
      serverUrl: string
      apiKey: string
    }
    bugsink?: {
      enabled: boolean
      dsn: string
    }
  }
}
```

- [ ] **Step 2: Verify type check passes**

```
npx astro check
```

Expected: 0 errors, 0 warnings (or at least no new errors introduced by this change).

- [ ] **Step 3: Commit**

```
git add src/world/types.ts
git commit -m "feat(world): expose bugReport on UiConfig"
```

---

## Task 3: Add real config to `ui.md`

**Files:**
- Modify: `src/world/ui.md`

- [ ] **Step 1: Edit frontmatter**

Insert this block in `src/world/ui.md` between the `features:` block and the closing `---`, so the frontmatter ends like:

```yaml
features:
  chips: true
  lightMeter: true
  typedEffect: true
  roomScroll: true
bugReport:
  enabled: true
  label: "Report a Bug"
  bugpin:
    serverUrl: "https://bugpin.half.st"
    apiKey: "proj_07df4bf91f12445b8ef8c723e865ed7b"
  bugsink:
    enabled: true
    dsn: "https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1"
---
```

- [ ] **Step 2: Verify the world loads**

```
npm test -- src/world/buildWorld.test.ts
npm test -- src/world/loader.test.ts
```

Expected: all tests pass — the new frontmatter is valid.

- [ ] **Step 3: Commit**

```
git add src/world/ui.md
git commit -m "feat(world): wire bugpin + bugsink credentials in ui.md"
```

---

## Task 4: Install `@sentry/browser`

**Files:**
- Modify: `package.json`, `package-lock.json`

- [ ] **Step 1: Install**

```
npm install @sentry/browser
```

Expected: a single new dependency added under `"dependencies"` in `package.json`; lockfile updates.

- [ ] **Step 2: Verify**

```
node -e "require('@sentry/browser')"
```

Expected: no output and exit code 0.

- [ ] **Step 3: Commit**

```
git add package.json package-lock.json
git commit -m "chore: add @sentry/browser for bugsink integration"
```

---

## Task 5: Create the Bugsink init module

**Files:**
- Create: `src/ui/error-tracking.ts`

No unit test — Vitest runs in node with no jsdom, and this module only does work in a browser. Coverage is `astro check` (types) + the manual smoke at the end.

- [ ] **Step 1: Write the module**

```ts
// src/ui/error-tracking.ts
import * as Sentry from '@sentry/browser'

const dsn = document.body?.dataset.bugsinkDsn ?? ''

if (dsn) {
  Sentry.init({
    dsn,
    tracesSampleRate: 0,
    replaysSessionSampleRate: 0,
    replaysOnErrorSampleRate: 0,
    integrations: [],
  })
}
```

- [ ] **Step 2: Type-check**

```
npx astro check
```

Expected: 0 new errors.

- [ ] **Step 3: Commit**

```
git add src/ui/error-tracking.ts
git commit -m "feat(ui): initialise sentry browser sdk against bugsink dsn"
```

---

## Task 6: Create the Bugpin trigger module

**Files:**
- Create: `src/ui/bug-report.ts`

Module reads config from the footer button's `data-*` attributes, lazy-loads the Bugpin widget on first click, then calls `BugPin.open()`. Subsequent clicks just call `open()` again.

- [ ] **Step 1: Write the module**

```ts
// src/ui/bug-report.ts

interface BugPinAPI {
  open: () => void
  close: () => void
}

declare global {
  interface Window {
    BugPin?: BugPinAPI
  }
}

const button = document.querySelector<HTMLButtonElement>('[data-bug-report-trigger]')

if (button) {
  const serverUrl = button.dataset.bugpinServer
  const apiKey = button.dataset.bugpinKey

  if (serverUrl && apiKey) {
    let loadPromise: Promise<void> | null = null

    const ensureLoaded = (): Promise<void> => {
      if (window.BugPin) return Promise.resolve()
      if (loadPromise) return loadPromise

      loadPromise = new Promise<void>((resolve, reject) => {
        const script = document.createElement('script')
        script.src = `${serverUrl.replace(/\/$/, '')}/widget.js`
        script.async = true
        script.dataset.apiKey = apiKey
        script.addEventListener('load', () => resolve())
        script.addEventListener('error', () => {
          loadPromise = null
          reject(new Error(`Failed to load Bugpin widget from ${script.src}`))
        })
        document.head.appendChild(script)
      })

      return loadPromise
    }

    button.addEventListener('click', async (event) => {
      event.preventDefault()
      button.disabled = true
      try {
        await ensureLoaded()
        window.BugPin?.open()
      } catch (err) {
        console.error('[bug-report]', err)
      } finally {
        button.disabled = false
      }
    })
  }
}
```

- [ ] **Step 2: Type-check**

```
npx astro check
```

Expected: 0 new errors.

- [ ] **Step 3: Commit**

```
git add src/ui/bug-report.ts
git commit -m "feat(ui): lazy-load bugpin widget on footer click"
```

---

## Task 7: Wire footer button and module imports in `index.astro`

**Files:**
- Modify: `src/pages/index.astro`

- [ ] **Step 1: Add config bindings to the frontmatter script**

In the `---` block at the top of `src/pages/index.astro`, after `const remainingFooterLinks = footerLinks.slice(1)`, add:

```ts
const bugReport = ui?.bugReport
const bugpinConfig = bugReport?.enabled ? bugReport.bugpin : undefined
const bugsinkDsn =
  bugReport?.enabled && bugReport.bugsink?.enabled ? bugReport.bugsink.dsn : ''
```

- [ ] **Step 2: Add the `data-bugsink-dsn` attribute to `<body>`**

Replace the existing `<body>` opening tag with:

```astro
<body data-bugsink-dsn={bugsinkDsn}>
```

- [ ] **Step 3: Add the footer button**

In the `<footer class="mystery-footer">` block (currently lines 89–109), append this AFTER the existing `{remainingFooterLinks.map(...)}` block, before the closing `</footer>`:

```astro
{bugpinConfig && (
  <>
    <span aria-hidden="true">|</span>
    <button
      type="button"
      class="mystery-footer-bug-report"
      data-bug-report-trigger
      data-bugpin-server={bugpinConfig.serverUrl}
      data-bugpin-key={bugpinConfig.apiKey}
    >{bugReport?.label ?? 'Report a Bug'}</button>
  </>
)}
```

- [ ] **Step 4: Import the two new modules**

Replace the existing module-import script block (currently lines 130–133):

```astro
<script>
  import '../ui/terminal.ts'
  import '../ui/theme.ts'
</script>
```

with:

```astro
<script>
  import '../ui/terminal.ts'
  import '../ui/theme.ts'
  import '../ui/error-tracking.ts'
  import '../ui/bug-report.ts'
</script>
```

- [ ] **Step 5: Add CSS so the button matches the surrounding links**

Open `src/ui/crt.css` and grep for `.mystery-footer` to find the existing footer styles. Add a rule that makes the button look identical to the footer anchors. Append this block at the end of the existing `.mystery-footer` rules (search for `.mystery-footer a` and add directly after that selector's rule):

```css
.mystery-footer-bug-report {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
}
.mystery-footer-bug-report:hover,
.mystery-footer-bug-report:focus-visible {
  text-decoration: none;
}
.mystery-footer-bug-report:disabled {
  opacity: 0.5;
  cursor: progress;
}
```

If `.mystery-footer a` styles its anchors differently (e.g. specific color, no underline by default), match those properties instead. Look at the actual `a` rule and copy its `color`/`text-decoration` declarations into `.mystery-footer-bug-report` so the two render visually identically.

- [ ] **Step 6: Run the full type check and build**

```
npx astro check
npm run build
```

Expected: 0 errors, build succeeds.

- [ ] **Step 7: Commit**

```
git add src/pages/index.astro src/ui/crt.css
git commit -m "feat(ui): add Report a Bug footer button and wire bug tracking modules"
```

---

## Task 8: Update TODOs

**Files:**
- Modify: `src/world/TODOs.md:45`

- [ ] **Step 1: Rewrite the TODO line**

Replace line 45 of `src/world/TODOs.md`:

```
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
```

with:

```
- [ ] Add a "Report a Bug" footer link backed by Bugpin (widget at bugpin.half.st → forwards to GitHub Issues). Add Bugsink (@sentry/browser → bugsink.half.st) for automatic JS error capture. Mark complete after manual verification: a test report appears in the Bugpin portal AND a GitHub issue is created AND a thrown error appears in the Bugsink portal.
```

- [ ] **Step 2: Commit**

```
git add src/world/TODOs.md
git commit -m "docs: rewrite TODO 45 to reflect Bugpin/Bugsink approach"
```

---

## Task 9: Manual smoke test

**Pre-req:** the Bugpin GitHub integration must be configured server-side before user reports will create issues. See spec §5. Steps below test the widget plumbing regardless.

- [ ] **Step 1: Start dev server**

```
npm run dev
```

Open the URL it prints (default `http://localhost:4321`).

- [ ] **Step 2: Verify the footer button renders**

In the rendered footer at the bottom of the page, confirm "Report a Bug" appears after "Source Code", styled like the surrounding links.

- [ ] **Step 3: Verify the widget lazy-loads**

Open DevTools → Network. Filter for `widget.js`. Confirm NO request to `bugpin.half.st/widget.js` on cold page load.

Click "Report a Bug". Confirm:
- A single GET to `https://bugpin.half.st/widget.js` (status 200).
- The Bugpin dialog opens.
- Take a screenshot, write "smoke test" in the note, submit.
- The dialog closes / shows a success state.

Re-click "Report a Bug". Confirm NO second `widget.js` request — only the dialog re-opens.

- [ ] **Step 4: Verify Bugpin received the report**

Open `https://bugpin.half.st/portal`. Confirm the "smoke test" report appears with the screenshot attached.

If the GitHub integration is configured: confirm a matching issue appears in the halfstreet GitHub repo.

- [ ] **Step 5: Verify Bugsink captures an uncaught error**

In the dev server's running page, open DevTools console and run:

```js
setTimeout(() => { throw new Error('bugsink smoke test') }, 0)
```

Open `https://bugsink.half.st`. Confirm a "bugsink smoke test" event appears within ~30 seconds.

- [ ] **Step 6: Verify the launcher styling is acceptable**

After step 3, the Bugpin widget's built-in floating launcher may now also be visible on the page. If its presence is visually objectionable, add a CSS rule to `src/ui/crt.css` targeting the Bugpin host element (inspect the DOM to find the selector — Bugpin uses Shadow DOM with a custom-element host like `<bugpin-widget>` or similar). Example:

```css
bugpin-widget,
[data-bugpin-launcher] {
  display: none !important;
}
```

If this turns out to be needed, commit the CSS as `style(ui): hide bugpin built-in launcher (we have our own trigger)`. If the launcher's appearance is fine as-is, skip this step.

- [ ] **Step 7: Mark TODO #45 complete**

Once steps 1–5 all pass, edit `src/world/TODOs.md` line 45, change `- [ ]` to `- [x]`, and commit:

```
git add src/world/TODOs.md
git commit -m "chore: mark TODO 45 complete"
```

---

## Done criteria

All of the following are true:

- `npm test` passes.
- `npx astro check` reports 0 errors.
- `npm run build` succeeds.
- Manual smoke (Task 9 steps 1–5) all pass.
- TODO #45 is `[x]`.
