From f811636985a3510e7a28213d2be1ba05f8f7859d Mon Sep 17 00:00:00 2001
From: acesonic7
Date: Wed, 7 Oct 2026 17:29:42 +0300
Subject: [PATCH] Add a Feedback dialog that opens a pre-filled GitHub issue
A Feedback button in the top bar, and links in the landing and app footers,
open a short form (something's wrong / an idea / a question, title, details,
optional app version and browser). It sends nothing itself: it opens GitHub's
bug or feature form, or a labelled question, filled in. The privacy page says
GitHub receives the text when its page loads. Also fixes invalid nesting in
the export cards' how-to text, which React reported as a hydration error.
Co-Authored-By: Claude Opus 5.5
---
app/components/TopBar.tsx | 2 +
app/components/export/PlatformCard.tsx | 2 +-
app/components/feedback/Feedback.tsx | 158 +++++++++++++++++++++++++
app/components/landing/Landing.tsx | 2 +
app/components/shell/AppShell.tsx | 2 +
app/privacy/page.tsx | 7 ++
lib/__tests__/feedback.test.ts | 50 ++++++++
lib/feedback.ts | 66 +++++++++++
8 files changed, 288 insertions(+), 1 deletion(-)
create mode 100644 app/components/feedback/Feedback.tsx
create mode 100644 lib/__tests__/feedback.test.ts
create mode 100644 lib/feedback.ts
diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx
index c917925..19231f0 100644
--- a/app/components/TopBar.tsx
+++ b/app/components/TopBar.tsx
@@ -11,6 +11,7 @@ import { useProgress } from './shell/Progress'
import { AltGlyph, Button, IconButton, Tag, cx } from './ui'
import { SavedIndicator } from './shell/SavedIndicator'
import { ThemeToggle } from './shell/ThemeToggle'
+import { FeedbackButton } from './feedback/Feedback'
// The primary action follows the first step not done yet; Export takes over at the end.
const NEXT_ACTION: Partial> = {
@@ -123,6 +124,7 @@ export default function TopBar({ project }: { project: Project }) {
{error && (
diff --git a/app/components/feedback/Feedback.tsx b/app/components/feedback/Feedback.tsx
new file mode 100644
index 0000000..2ecaca0
--- /dev/null
+++ b/app/components/feedback/Feedback.tsx
@@ -0,0 +1,158 @@
+'use client'
+
+import { useEffect, useId, useRef, useState } from 'react'
+import { describeBrowser, feedbackUrl, MAX_MESSAGE, type FeedbackKind } from '@/lib/feedback'
+import { APP_VERSION } from '../shell/Rail'
+import { Button, Checkbox, Field, Input, Seg, Textarea, cx } from '../ui'
+import { XMark } from '../Icons'
+
+const KINDS: { value: FeedbackKind; label: string }[] = [
+ { value: 'bug', label: 'Something’s wrong' },
+ { value: 'idea', label: 'An idea' },
+ { value: 'question', label: 'A question' },
+]
+
+const PLACEHOLDER: Record = {
+ bug: {
+ title: 'e.g. QSF import shows every block',
+ message: 'What did you do, what did you expect, and what happened instead?',
+ },
+ idea: {
+ title: 'e.g. Presets for travel time and cost',
+ message: 'What are you trying to do, and where does UtilityLab fall short?',
+ },
+ question: { title: 'e.g. How should I set priors for cost?', message: 'Your question' },
+}
+
+function FeedbackDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const ref = useRef(null)
+ const uid = useId()
+ const [kind, setKind] = useState('bug')
+ const [title, setTitle] = useState('')
+ const [message, setMessage] = useState('')
+ const [withContext, setWithContext] = useState(true)
+
+ useEffect(() => {
+ const d = ref.current
+ if (!d) return
+ if (open && !d.open) d.showModal()
+ else if (!open && d.open) d.close()
+ }, [open])
+
+ const send = () => {
+ const url = feedbackUrl({
+ kind,
+ title,
+ message,
+ context: withContext ? { version: APP_VERSION, browser: describeBrowser(navigator.userAgent) } : undefined,
+ })
+ window.open(url, '_blank', 'noopener,noreferrer')
+ onClose()
+ }
+
+ return (
+
+ )
+}
+
+// A button that opens its own feedback dialog. `look` matches where it sits.
+export function FeedbackButton({ look = 'link', className }: { look?: 'link' | 'button'; className?: string }) {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+ {look === 'button' ? (
+
+ ) : (
+
+ )}
+ setOpen(false)} />
+ >
+ )
+}
diff --git a/app/components/landing/Landing.tsx b/app/components/landing/Landing.tsx
index 3940fc3..0866de3 100644
--- a/app/components/landing/Landing.tsx
+++ b/app/components/landing/Landing.tsx
@@ -8,6 +8,7 @@ import { ChevronRight, Upload } from '../Icons'
import { SkipLink } from '../shell/AppShell'
import { APP_VERSION, CreditLink } from '../shell/Rail'
import { LegalLinks } from '../legal/LegalLinks'
+import { FeedbackButton } from '../feedback/Feedback'
import { ThemeToggle } from '../shell/ThemeToggle'
import { Button, buttonBase, buttonVariants, cx } from '../ui'
import { Film } from './Film'
@@ -177,6 +178,7 @@ export function Landing({
UtilityLab v{APP_VERSION}
+
diff --git a/app/components/shell/AppShell.tsx b/app/components/shell/AppShell.tsx
index 65ddb1d..7bd948f 100644
--- a/app/components/shell/AppShell.tsx
+++ b/app/components/shell/AppShell.tsx
@@ -10,6 +10,7 @@ import { useReviewOnDwell } from './Progress'
import { ExampleBanner } from '../library/ExampleBanner'
import { Walkthrough, WalkthroughLink } from './Walkthrough'
import { LegalLinks } from '../legal/LegalLinks'
+import { FeedbackButton } from '../feedback/Feedback'
export const canvasClass = 'min-w-0 px-4 pb-24 pt-8 sm:px-8 lg:px-11 lg:pt-10'
export const contentClass = 'mx-auto max-w-[1120px]'
@@ -59,6 +60,7 @@ export function AppShell({
+
+ Feedback. The Feedback form does not send anything
+ itself: it opens GitHub in a new tab with your text filled in, so GitHub receives that text, and the app
+ version and browser if you leave them ticked, when its page loads. Nothing is published until you submit it
+ there, under{' '}
+ GitHub’s privacy statement.
+
Images you link. If you add an image address to an
alternative, attribute or level, your browser loads the image from that site, which can then see your IP
diff --git a/lib/__tests__/feedback.test.ts b/lib/__tests__/feedback.test.ts
new file mode 100644
index 0000000..7f45aec
--- /dev/null
+++ b/lib/__tests__/feedback.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { MAX_MESSAGE, describeBrowser, feedbackUrl } from '../feedback'
+
+const UA = {
+ firefoxMac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:131.0) Gecko/20100101 Firefox/131.0',
+ chromeWin: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
+ edge: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.79',
+ safariIos: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
+}
+
+const params = (url: string) => new URL(url).searchParams
+
+describe('describeBrowser', () => {
+ it('names common browsers and systems', () => {
+ expect(describeBrowser(UA.firefoxMac)).toBe('Firefox 131 on macOS')
+ expect(describeBrowser(UA.chromeWin)).toBe('Chrome 129 on Windows')
+ expect(describeBrowser(UA.edge)).toBe('Edge 129 on Windows')
+ expect(describeBrowser(UA.safariIos)).toBe('Safari 18 on iOS')
+ expect(describeBrowser('')).toBe('Unknown browser')
+ })
+})
+
+describe('feedbackUrl', () => {
+ it('fills the bug report form, with context only when asked', () => {
+ const p = params(feedbackUrl({ kind: 'bug', title: 'QSF import fails', message: 'Error on import', context: { version: '1.2.0', browser: 'Firefox 131 on macOS' } }))
+ expect(p.get('template')).toBe('bug_report.yml')
+ expect(p.get('title')).toBe('QSF import fails')
+ expect(p.get('what')).toBe('Error on import')
+ expect(p.get('browser')).toBe('Firefox 131 on macOS')
+ expect(p.get('version')).toBe('v1.2.0')
+ const bare = params(feedbackUrl({ kind: 'bug', title: 'x', message: 'y' }))
+ expect(bare.has('browser')).toBe(false)
+ })
+ it('fills the feature request form', () => {
+ const p = params(feedbackUrl({ kind: 'idea', title: 'Attribute library', message: 'Travel time presets' }))
+ expect(p.get('template')).toBe('feature_request.yml')
+ expect(p.get('need')).toBe('Travel time presets')
+ })
+ it('opens a labelled blank issue for questions', () => {
+ const p = params(feedbackUrl({ kind: 'question', title: 'Priors?', message: 'How?', context: { version: '1.2.0', browser: 'Chrome 129 on Windows' } }))
+ expect(p.get('labels')).toBe('question')
+ expect(p.get('body')).toContain('How?')
+ expect(p.get('body')).toContain('UtilityLab v1.2.0 · Chrome 129 on Windows')
+ })
+ it('shortens long messages so the link stays usable', () => {
+ const url = feedbackUrl({ kind: 'idea', title: 't', message: 'a'.repeat(MAX_MESSAGE + 500) })
+ expect(params(url).get('need')!.endsWith('[shortened to fit the link]')).toBe(true)
+ expect(url.length).toBeLessThan(8000)
+ })
+})
diff --git a/lib/feedback.ts b/lib/feedback.ts
new file mode 100644
index 0000000..563aae5
--- /dev/null
+++ b/lib/feedback.ts
@@ -0,0 +1,66 @@
+// Feedback goes to GitHub as a pre-filled issue. Nothing is sent from the app: the link opens
+// GitHub's issue form, filled in, and the person submits it there.
+
+export const REPO_ISSUES = 'https://github.com/acesonic7/utilitylab/issues/new'
+
+export type FeedbackKind = 'bug' | 'idea' | 'question'
+
+export type FeedbackInput = {
+ kind: FeedbackKind
+ title: string
+ message: string
+ // Added only when the person ticks "include app version and browser".
+ context?: { version: string; browser: string }
+}
+
+// Keeps the link well under the length browsers and GitHub accept.
+export const MAX_MESSAGE = 3000
+
+// A short "Firefox 131 on macOS" from a user agent string.
+export function describeBrowser(ua: string): string {
+ const version = (re: RegExp) => ua.match(re)?.[1]?.split('.')[0]
+ let browser = 'Unknown browser'
+ if (/Edg\//.test(ua)) browser = `Edge ${version(/Edg\/([\d.]+)/)}`
+ else if (/OPR\//.test(ua)) browser = `Opera ${version(/OPR\/([\d.]+)/)}`
+ else if (/Firefox\//.test(ua)) browser = `Firefox ${version(/Firefox\/([\d.]+)/)}`
+ else if (/Chrome\//.test(ua)) browser = `Chrome ${version(/Chrome\/([\d.]+)/)}`
+ else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = `Safari ${version(/Version\/([\d.]+)/)}`
+ let os = ''
+ if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS'
+ else if (/Android/.test(ua)) os = 'Android'
+ else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS'
+ else if (/Windows/.test(ua)) os = 'Windows'
+ else if (/CrOS/.test(ua)) os = 'ChromeOS'
+ else if (/Linux/.test(ua)) os = 'Linux'
+ return os ? `${browser} on ${os}` : browser
+}
+
+function clip(text: string): string {
+ const t = text.trim()
+ return t.length > MAX_MESSAGE ? `${t.slice(0, MAX_MESSAGE)}\n\n[shortened to fit the link]` : t
+}
+
+export function feedbackUrl(input: FeedbackInput): string {
+ const params = new URLSearchParams()
+ const title = input.title.trim()
+ const message = clip(input.message)
+ if (input.kind === 'bug') {
+ params.set('template', 'bug_report.yml')
+ if (title) params.set('title', title)
+ if (message) params.set('what', message)
+ if (input.context) {
+ params.set('browser', input.context.browser)
+ params.set('version', `v${input.context.version}`)
+ }
+ } else if (input.kind === 'idea') {
+ params.set('template', 'feature_request.yml')
+ if (title) params.set('title', title)
+ if (message) params.set('need', message)
+ } else {
+ params.set('labels', 'question')
+ if (title) params.set('title', title)
+ const context = input.context ? `\n\n---\nUtilityLab v${input.context.version} · ${input.context.browser}` : ''
+ if (message || context) params.set('body', `${message}${context}`)
+ }
+ return `${REPO_ISSUES}?${params.toString()}`
+}