feat(ui): add bug reporting integrations

This commit is contained in:
2026-05-17 23:34:17 -05:00
parent a51bb6f86f
commit 18aa517319
11 changed files with 354 additions and 3 deletions
+76
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
@@ -1766,6 +1767,81 @@
"win32"
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@shikijs/core": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
+1
View File
@@ -18,6 +18,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
+21 -1
View File
@@ -7,6 +7,10 @@ const ui = world.ui
const footerLinks = ui?.footer.links ?? []
const firstFooterLink = footerLinks[0]
const remainingFooterLinks = footerLinks.slice(1)
const bugReport = ui?.bugReport
const bugpinConfig = bugReport?.enabled ? bugReport.bugpin : undefined
const bugsinkDsn =
bugReport?.enabled && bugReport.bugsink?.enabled ? bugReport.bugsink.dsn : ''
---
<html lang="en">
@@ -23,7 +27,7 @@ const remainingFooterLinks = footerLinks.slice(1)
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content={ui?.themeColor ?? '#1a0d00'} />
</head>
<body>
<body data-bugsink-dsn={bugsinkDsn}>
<div class="mystery-root" data-mystery-root>
<div class="mystery-bezel">
<div class="mystery-options" data-mystery-options>
@@ -106,6 +110,20 @@ const remainingFooterLinks = footerLinks.slice(1)
<a href={link.href}>{link.label}</a>
</>
))}
{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}
aria-describedby="bug-report-status"
>{bugReport?.label ?? 'Report a Bug'}</button>
<span id="bug-report-status" class="mystery-footer-status" aria-live="polite"></span>
</>
)}
</footer>
</div>
<script>
@@ -130,6 +148,8 @@ const remainingFooterLinks = footerLinks.slice(1)
<script>
import '../ui/terminal.ts'
import '../ui/theme.ts'
import '../ui/error-tracking.ts'
import '../ui/bug-report.ts'
</script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
interface BugPinAPI {
init: (config: { apiKey: string, serverUrl: string }) => Promise<void> | void
open: () => void
close: () => void
}
declare global {
interface Window {
BugPin?: BugPinAPI
}
}
const launcherSelector = [
'.fixed.bottom-5.right-5',
'.fixed.bottom-5.left-5',
'.fixed.top-5.right-5',
'.fixed.top-5.left-5',
].join(',')
const button = document.querySelector<HTMLButtonElement>('[data-bug-report-trigger]')
let loadPromise: Promise<void> | null = null
let initialized = false
let launcherObserver: MutationObserver | null = null
function setStatus(message: string): void {
const targetId = button?.getAttribute('aria-describedby')
const target = targetId ? document.getElementById(targetId) : null
if (target) target.textContent = message
}
function buildWidgetSrc(serverUrl: string): string {
return `${serverUrl.replace(/\/$/, '')}/widget.js`
}
function loadWidget(serverUrl: string): 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 = buildWidgetSrc(serverUrl)
script.async = true
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
}
function hideBugPinLauncher(): void {
const host = document.getElementById('bugpin-widget')
const root = host?.shadowRoot
if (!root) return
for (const launcher of root.querySelectorAll<HTMLElement>(launcherSelector)) {
launcher.hidden = true
launcher.style.display = 'none'
launcher.setAttribute('aria-hidden', 'true')
}
}
function watchBugPinLauncher(): void {
const host = document.getElementById('bugpin-widget')
const root = host?.shadowRoot
if (!root || launcherObserver) return
hideBugPinLauncher()
launcherObserver = new MutationObserver(() => hideBugPinLauncher())
launcherObserver.observe(root, { childList: true, subtree: true })
}
function waitForBugPinEffects(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0))
})
}
async function openBugReport(serverUrl: string, apiKey: string): Promise<void> {
await loadWidget(serverUrl)
if (!window.BugPin) {
throw new Error('BugPin widget loaded, but window.BugPin is unavailable')
}
if (!initialized) {
await window.BugPin.init({ apiKey, serverUrl })
initialized = true
watchBugPinLauncher()
await waitForBugPinEffects()
}
hideBugPinLauncher()
window.BugPin.open()
hideBugPinLauncher()
}
if (button) {
const serverUrl = button.dataset.bugpinServer
const apiKey = button.dataset.bugpinKey
if (serverUrl && apiKey) {
button.addEventListener('click', async (event) => {
event.preventDefault()
setStatus('')
button.disabled = true
try {
await openBugReport(serverUrl, apiKey)
} catch (err) {
console.error('[bug-report]', err)
setStatus("Couldn't open bug reporter - try refreshing.")
} finally {
button.disabled = false
}
})
}
}
if (window.BugPin) {
requestAnimationFrame(() => watchBugPinLauncher())
}
export {}
+25 -1
View File
@@ -89,11 +89,35 @@ body {
text-align: center;
}
.mystery-footer a {
.mystery-footer a,
.mystery-footer-bug-report {
color: var(--m-fg);
text-decoration: none;
}
.mystery-footer-bug-report {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
}
.mystery-footer-bug-report:hover,
.mystery-footer-bug-report:focus-visible {
text-decoration: underline;
}
.mystery-footer-bug-report:disabled {
cursor: progress;
opacity: 0.55;
}
.mystery-footer-status {
color: var(--m-fg);
}
.mystery-footer span {
margin: 0 0.5ch;
}
+12
View File
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/browser'
const dsn = document.body?.dataset.bugsinkDsn ?? ''
if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
})
}
+1 -1
View File
@@ -42,7 +42,7 @@
- [ ] FEATURE: Add item Revolver, which can be used to kill the thing at the end. It comes with no bullets, so players need to defeat the safe-cracking minjgame. Place the revolver somewhere it takes the player some effort to find.
- [ ] Feature: A faceless voice that speaks in a whisper in the smoking room, which demands whiskey and dispenses riddles if the player finds a bottle of whiskey (but hasn't drunk it yet). The riddles are randomly chosen from a list of 25 difficult riddles (source them). If the riddle is answered correctly there needs to be some reward and maybe a major plot point is revealed.
- [x] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before. When the player is "drunk" the text on the terminal should appear blurry, like the user has double vision.
- [ ] 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.
- [ ] 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.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
+59
View File
@@ -117,6 +117,65 @@ describe('uiFrontmatterSchema', () => {
}
expect(() => uiFrontmatterSchema.parse(data)).toThrow()
})
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()
})
})
describe('lightMechanicFrontmatterSchema', () => {
+12
View File
@@ -71,6 +71,18 @@ export const uiFrontmatterSchema = z.object({
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(),
})
export type UiFrontmatter = z.infer<typeof uiFrontmatterSchema>
+12
View File
@@ -82,6 +82,18 @@ export interface UiConfig {
typedEffect: boolean
roomScroll: boolean
}
bugReport?: {
enabled: boolean
label: string
bugpin?: {
serverUrl: string
apiKey: string
}
bugsink?: {
enabled: boolean
dsn: string
}
}
}
export type LightBurnTrigger = 'move' | 'wait'
+9
View File
@@ -18,6 +18,15 @@ features:
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"
---
# UI