@1agh/maude 0.51.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,6 +78,7 @@ import { PhotoKnobs } from './photo-knobs.jsx';
78
78
  import {
79
79
  appIsFirstRun,
80
80
  isNativeApp,
81
+ onMenuReportBug,
81
82
  onUpdateReady,
82
83
  pickMediaFile,
83
84
  pickMediaFiles,
@@ -88,6 +89,7 @@ import { TourOverlay } from './tour/overlay.jsx';
88
89
  import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
89
90
  import { USAGE_TOUR } from './tour/usage-tour.js';
90
91
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
92
+ import { ReportBugDialog } from './report-bug.jsx';
91
93
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
92
94
 
93
95
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
@@ -3408,6 +3410,7 @@ function HelpDropdown({ onAction, onClose }) {
3408
3410
  items={[
3409
3411
  { id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
3410
3412
  { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
3413
+ { id: 'report-bug', label: 'Report a bug…' },
3411
3414
  { sep: true },
3412
3415
  { id: 'tour', label: 'Take the tour' },
3413
3416
  { id: 'watch-intro', label: 'Watch the intro' },
@@ -3552,6 +3555,7 @@ function Menubar({
3552
3555
  onToggleShowHidden,
3553
3556
  onOpenHelp,
3554
3557
  onOpenShortcuts,
3558
+ onReportBug,
3555
3559
  onStartTour,
3556
3560
  onStartCollabTour,
3557
3561
  annotationsVisible,
@@ -3878,6 +3882,7 @@ function Menubar({
3878
3882
  onAction={(id) => {
3879
3883
  if (id === 'shortcuts') onOpenShortcuts?.();
3880
3884
  else if (id === 'help') onOpenHelp?.();
3885
+ else if (id === 'report-bug') onReportBug?.();
3881
3886
  else if (id === 'tour') onStartTour?.();
3882
3887
  else if (id === 'collab-tour') onStartCollabTour?.();
3883
3888
  else if (id === 'quick-setup') onOpenQuickSetup?.();
@@ -9158,6 +9163,17 @@ function App() {
9158
9163
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
9159
9164
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
9160
9165
  const [helpOpen, setHelpOpen] = useState(false);
9166
+ const [reportBugOpen, setReportBugOpen] = useState(false);
9167
+
9168
+ // Native Help ▸ Report a Bug… (menu.rs emits `menu://report-bug`) — same
9169
+ // lane as IdentityBar's File ▸ New Project… subscription.
9170
+ useEffect(() => {
9171
+ if (!isNativeApp()) return;
9172
+ const p = onMenuReportBug(() => setReportBugOpen(true));
9173
+ return () => {
9174
+ p.then((un) => un()).catch(() => {});
9175
+ };
9176
+ }, []);
9161
9177
  const [readinessOpen, setReadinessOpen] = useState(false);
9162
9178
  const [introOpen, setIntroOpen] = useState(false);
9163
9179
  const [quickSetupOpen, setQuickSetupOpen] = useState(false);
@@ -13309,6 +13325,13 @@ function App() {
13309
13325
  kbd: 'F1',
13310
13326
  run: () => setHelpOpen(true),
13311
13327
  },
13328
+ {
13329
+ id: 'report-bug',
13330
+ group: 'Help',
13331
+ label: 'Report a bug…',
13332
+ icon: 'help',
13333
+ run: () => setReportBugOpen(true),
13334
+ },
13312
13335
  ],
13313
13336
  [openSystem, toggleTheme, reloadActive, whatsNew]
13314
13337
  );
@@ -13522,6 +13545,7 @@ function App() {
13522
13545
  onToggleShowHidden={() => setShowHidden((v) => !v)}
13523
13546
  onOpenHelp={() => setHelpOpen(true)}
13524
13547
  onOpenShortcuts={() => setShortcutsOpen(true)}
13548
+ onReportBug={() => setReportBugOpen(true)}
13525
13549
  onStartTour={() => startTour(USAGE_TOUR)}
13526
13550
  onStartCollabTour={() => startTour(COLLAB_TOUR)}
13527
13551
  annotationsVisible={annotationsVisible}
@@ -14182,6 +14206,7 @@ function App() {
14182
14206
  startTour(USAGE_TOUR);
14183
14207
  }}
14184
14208
  />
14209
+ <ReportBugDialog open={reportBugOpen} onClose={() => setReportBugOpen(false)} />
14185
14210
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
14186
14211
  <ExportPanel center={exportCenter} />
14187
14212
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
@@ -45,6 +45,8 @@ export const onDeviceCode = (cb) => listen('github://device-code', cb);
45
45
  export const onSignedIn = (cb) => listen('github://signed-in', cb);
46
46
  /** Fire when the native File ▸ New Project… menu item is chosen. Returns an unlisten promise. */
47
47
  export const onMenuNewProject = (cb) => listen('menu://new-project', cb);
48
+ /** Fire when the native Help ▸ Report a Bug… menu item is chosen. Returns an unlisten promise. */
49
+ export const onMenuReportBug = (cb) => listen('menu://report-bug', cb);
48
50
 
49
51
  // ── dev-server endpoints ────────────────────────────────────────────────────────
50
52
  async function api(path, opts = {}) {
@@ -0,0 +1,590 @@
1
+ // client/report-bug.jsx — the Report-a-Bug dialog (feature-bug-report-button).
2
+ //
3
+ // Consent-first by construction (docs/report-schema.md): the dialog's middle
4
+ // step IS the payload preview — every optional item (screenshot, log tail,
5
+ // project name, active canvas, crash logs) has its own checkbox, description
6
+ // is the only mandatory field, and nothing transmits until "Send report".
7
+ // Screenshots can be redacted in place (black-box rects, flattened into the
8
+ // PNG client-side — the original pixels never leave the machine).
9
+ //
10
+ // Transport: POST /_api/report (dev-server proxy → cloud intake). Cloud down
11
+ // or declined → POST /_api/report-fallback writes the bundle under
12
+ // `<designRoot>/_reports/` and we open a prefilled public-issue URL (text
13
+ // only — media never goes to the public repo).
14
+
15
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
16
+
17
+ import { invoke, isNativeApp, openGitHubUrl } from './github.js';
18
+
19
+ const MAX_SHOTS = 3;
20
+ const PUBLIC_ISSUES_URL = 'https://github.com/1aGh/maude/issues/new';
21
+
22
+ function randHex(n) {
23
+ const bytes = new Uint8Array(n / 2);
24
+ crypto.getRandomValues(bytes);
25
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
26
+ }
27
+
28
+ function installId() {
29
+ try {
30
+ let id = localStorage.getItem('maude-install-id');
31
+ if (!id) {
32
+ id = `i-${randHex(12)}`;
33
+ localStorage.setItem('maude-install-id', id);
34
+ }
35
+ return id;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Flatten one screenshot: source image + redaction rects → PNG blob. */
42
+ async function flattenShot(shot) {
43
+ const img = new Image();
44
+ img.src = shot.url;
45
+ await img.decode();
46
+ const canvas = document.createElement('canvas');
47
+ canvas.width = img.naturalWidth;
48
+ canvas.height = img.naturalHeight;
49
+ const c2d = canvas.getContext('2d');
50
+ c2d.drawImage(img, 0, 0);
51
+ c2d.fillStyle = '#000';
52
+ for (const r of shot.rects) {
53
+ c2d.fillRect(
54
+ r.x * img.naturalWidth,
55
+ r.y * img.naturalHeight,
56
+ r.w * img.naturalWidth,
57
+ r.h * img.naturalHeight
58
+ );
59
+ }
60
+ return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
61
+ }
62
+
63
+ const blobToDataUrl = (blob) =>
64
+ new Promise((resolve) => {
65
+ const fr = new FileReader();
66
+ fr.onload = () => resolve(fr.result);
67
+ fr.readAsDataURL(blob);
68
+ });
69
+
70
+ /** One screenshot preview tile: redact-mode drag draws normalized black rects. */
71
+ function ShotTile({ shot, redacting, onRects, onRemove }) {
72
+ const boxRef = useRef(null);
73
+ const dragRef = useRef(null);
74
+ const [draft, setDraft] = useState(null);
75
+
76
+ const norm = (e) => {
77
+ const b = boxRef.current.getBoundingClientRect();
78
+ return {
79
+ x: Math.min(Math.max((e.clientX - b.left) / b.width, 0), 1),
80
+ y: Math.min(Math.max((e.clientY - b.top) / b.height, 0), 1),
81
+ };
82
+ };
83
+
84
+ return (
85
+ <div className="rb-shot" data-testid="report-bug-shot">
86
+ <div
87
+ ref={boxRef}
88
+ className={`rb-shot-box${redacting ? ' is-redacting' : ''}`}
89
+ onPointerDown={(e) => {
90
+ if (!redacting) return;
91
+ e.currentTarget.setPointerCapture(e.pointerId);
92
+ dragRef.current = norm(e);
93
+ setDraft({ ...dragRef.current, w: 0, h: 0 });
94
+ }}
95
+ onPointerMove={(e) => {
96
+ if (!redacting || !dragRef.current) return;
97
+ const p = norm(e);
98
+ const s = dragRef.current;
99
+ setDraft({
100
+ x: Math.min(s.x, p.x),
101
+ y: Math.min(s.y, p.y),
102
+ w: Math.abs(p.x - s.x),
103
+ h: Math.abs(p.y - s.y),
104
+ });
105
+ }}
106
+ onPointerUp={() => {
107
+ if (!redacting || !dragRef.current) return;
108
+ dragRef.current = null;
109
+ if (draft && draft.w > 0.005 && draft.h > 0.005) onRects([...shot.rects, draft]);
110
+ setDraft(null);
111
+ }}
112
+ >
113
+ <img src={shot.url} alt="screenshot to attach" draggable={false} />
114
+ {[...shot.rects, ...(draft ? [draft] : [])].map((r, i) => (
115
+ <div
116
+ key={i}
117
+ className="rb-redact-rect"
118
+ style={{
119
+ left: `${r.x * 100}%`,
120
+ top: `${r.y * 100}%`,
121
+ width: `${r.w * 100}%`,
122
+ height: `${r.h * 100}%`,
123
+ }}
124
+ />
125
+ ))}
126
+ </div>
127
+ <div className="rb-shot-actions">
128
+ {shot.rects.length > 0 && (
129
+ <button type="button" className="rb-link" onClick={() => onRects([])}>
130
+ Clear redactions
131
+ </button>
132
+ )}
133
+ <button type="button" className="rb-link" data-testid="report-bug-shot-remove" onClick={onRemove}>
134
+ Remove
135
+ </button>
136
+ </div>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export function ReportBugDialog({ open, onClose }) {
142
+ const [step, setStep] = useState('describe'); // describe | preview | sending | done | fallback
143
+ const [description, setDescription] = useState('');
144
+ const [bundle, setBundle] = useState(null);
145
+ const [shots, setShots] = useState([]); // {url, blob, rects:[{x,y,w,h} normalized]}
146
+ const [crashLogs, setCrashLogs] = useState([]); // native only: {name, firstLine}
147
+ const [consent, setConsent] = useState({
148
+ logTail: true,
149
+ activeCanvas: true,
150
+ projectName: false,
151
+ crashLogs: true,
152
+ });
153
+ const [redacting, setRedacting] = useState(false);
154
+ const [result, setResult] = useState(null); // {issueUrl, issueNumber} | {fallbackDir}
155
+ const [error, setError] = useState(null);
156
+
157
+ const reset = useCallback(() => {
158
+ for (const s of shots) URL.revokeObjectURL(s.url);
159
+ setStep('describe');
160
+ setDescription('');
161
+ setBundle(null);
162
+ setShots([]);
163
+ setCrashLogs([]);
164
+ setRedacting(false);
165
+ setResult(null);
166
+ setError(null);
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [shots]);
169
+
170
+ const addShotBlob = useCallback((blob) => {
171
+ if (!blob) return;
172
+ setShots((prev) =>
173
+ prev.length >= MAX_SHOTS
174
+ ? prev
175
+ : [...prev, { url: URL.createObjectURL(blob), blob, rects: [] }]
176
+ );
177
+ }, []);
178
+
179
+ // On open: pull the scrubbed bundle, capture the active artboard, list
180
+ // native crash logs. Every leg fails soft — a report with fewer attachments
181
+ // beats no dialog.
182
+ useEffect(() => {
183
+ if (!open) return;
184
+ let alive = true;
185
+ fetch('/_api/debug-bundle')
186
+ .then((r) => (r.ok ? r.json() : null))
187
+ .then((b) => alive && setBundle(b))
188
+ .catch(() => {});
189
+ fetch('/_api/export', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({ format: 'png', scope: 'artboard' }),
193
+ })
194
+ .then((r) => (r.ok ? r.blob() : null))
195
+ .then((blob) => alive && blob && addShotBlob(blob))
196
+ .catch(() => {});
197
+ if (isNativeApp()) {
198
+ invoke('list_crash_logs')
199
+ .then((logs) => alive && setCrashLogs(Array.isArray(logs) ? logs.slice(0, 3) : []))
200
+ .catch(() => {});
201
+ }
202
+ return () => {
203
+ alive = false;
204
+ };
205
+ // eslint-disable-next-line react-hooks/exhaustive-deps
206
+ }, [open]);
207
+
208
+ // Paste-to-attach — the lane that covers native-shell bugs the canvas
209
+ // capture can't see (user screenshots the window, pastes here).
210
+ useEffect(() => {
211
+ if (!open) return;
212
+ function onPaste(e) {
213
+ for (const item of e.clipboardData?.items ?? []) {
214
+ if (item.type.startsWith('image/')) addShotBlob(item.getAsFile());
215
+ }
216
+ }
217
+ window.addEventListener('paste', onPaste);
218
+ return () => window.removeEventListener('paste', onPaste);
219
+ }, [open, addShotBlob]);
220
+
221
+ if (!open) return null;
222
+
223
+ const close = () => {
224
+ reset();
225
+ onClose();
226
+ };
227
+
228
+ async function buildReport() {
229
+ const report = {
230
+ schema: 'maude-report/v1',
231
+ reportId: `r-${randHex(8)}`,
232
+ createdAt: new Date().toISOString(),
233
+ app: {
234
+ maudeVersion: bundle?.app?.maudeVersion ?? 'unknown',
235
+ surface: isNativeApp() ? 'native' : 'browser',
236
+ platform: bundle?.app?.platform ?? 'unknown',
237
+ arch: bundle?.app?.arch ?? 'unknown',
238
+ },
239
+ context: {
240
+ projectName: consent.projectName ? (bundle?.context?.projectName ?? null) : null,
241
+ activeCanvas: consent.activeCanvas ? (bundle?.context?.activeCanvas ?? null) : null,
242
+ },
243
+ description: description.trim(),
244
+ attachments: {
245
+ screenshots: shots.length,
246
+ serverLogTail: consent.logTail && !!bundle?.logs?.serverLogTail,
247
+ crashLogs: consent.crashLogs ? crashLogs.length : 0,
248
+ },
249
+ logs: {},
250
+ };
251
+ const id = installId();
252
+ if (id) report.installId = id;
253
+ if (consent.logTail && bundle?.logs?.serverLogTail) {
254
+ report.logs.serverLogTail = bundle.logs.serverLogTail;
255
+ }
256
+ if (consent.crashLogs && crashLogs.length && isNativeApp()) {
257
+ report.logs.crashLogs = [];
258
+ for (const log of crashLogs) {
259
+ try {
260
+ const body = await invoke('read_crash_log', { name: log.name });
261
+ report.logs.crashLogs.push({ name: log.name, body });
262
+ } catch {
263
+ /* an unreadable crash log is dropped, not fatal */
264
+ }
265
+ }
266
+ }
267
+ return report;
268
+ }
269
+
270
+ async function send() {
271
+ setStep('sending');
272
+ setError(null);
273
+ try {
274
+ const report = await buildReport();
275
+ const form = new FormData();
276
+ form.set('report', JSON.stringify(report));
277
+ for (const shot of shots) {
278
+ form.append('screenshot', await flattenShot(shot), 'screenshot.png');
279
+ }
280
+ const res = await fetch('/_api/report', { method: 'POST', body: form });
281
+ const json = await res.json().catch(() => null);
282
+ if (res.ok && json?.ok) {
283
+ setResult(json);
284
+ setStep('done');
285
+ return;
286
+ }
287
+ setError(json?.error ?? `report failed (HTTP ${res.status})`);
288
+ setStep('fallback');
289
+ } catch {
290
+ setError('Maude could not reach the report service.');
291
+ setStep('fallback');
292
+ }
293
+ }
294
+
295
+ async function saveFallback() {
296
+ try {
297
+ const report = await buildReport();
298
+ const screenshots = [];
299
+ for (const shot of shots) screenshots.push(await blobToDataUrl(await flattenShot(shot)));
300
+ const res = await fetch('/_api/report-fallback', {
301
+ method: 'POST',
302
+ headers: { 'Content-Type': 'application/json' },
303
+ body: JSON.stringify({ report, screenshots }),
304
+ });
305
+ const json = await res.json().catch(() => null);
306
+ const title = encodeURIComponent(report.description.split('\n')[0].slice(0, 80));
307
+ const body = encodeURIComponent(
308
+ `${report.description}\n\n> Maude ${report.app.maudeVersion} · ${report.app.platform} ${report.app.arch} · ${report.app.surface}\n> Local report bundle: \`${json?.dir ?? '.design/_reports/'}\` (attach its files here)`
309
+ );
310
+ const url = `${PUBLIC_ISSUES_URL}?title=${title}&body=${body}&labels=report`;
311
+ if (isNativeApp()) {
312
+ try {
313
+ await openGitHubUrl(url);
314
+ } catch {
315
+ window.open(url, '_blank', 'noopener');
316
+ }
317
+ } else {
318
+ window.open(url, '_blank', 'noopener');
319
+ }
320
+ setResult({ fallbackDir: json?.dir ?? null });
321
+ setStep('done');
322
+ } catch {
323
+ setError('Saving the local bundle failed too — please file the issue by hand.');
324
+ }
325
+ }
326
+
327
+ const canPreview = description.trim().length > 0;
328
+
329
+ return (
330
+ <div className="help-modal-backdrop" onClick={close} data-testid="report-bug-dialog">
331
+ <div
332
+ className="help-modal rb-modal"
333
+ role="dialog"
334
+ aria-modal="true"
335
+ aria-labelledby="rb-title"
336
+ onClick={(e) => e.stopPropagation()}
337
+ >
338
+ <header className="help-modal-hd">
339
+ <span className="title" id="rb-title">
340
+ Report a bug
341
+ </span>
342
+ <button type="button" className="help-modal-close" onClick={close} aria-label="Close">
343
+ ×
344
+ </button>
345
+ </header>
346
+
347
+ <div className="help-modal-body rb-body">
348
+ {step === 'describe' && (
349
+ <>
350
+ <label className="rb-label" htmlFor="rb-desc">
351
+ What happened? What did you expect instead?
352
+ </label>
353
+ <textarea
354
+ id="rb-desc"
355
+ className="rb-desc"
356
+ data-testid="report-bug-description"
357
+ rows={5}
358
+ autoFocus
359
+ value={description}
360
+ onChange={(e) => setDescription(e.target.value)}
361
+ placeholder="e.g. Clicking Export → PNG shows a spinner forever…"
362
+ />
363
+ <p className="rb-hint">
364
+ Next you'll see exactly what gets attached — nothing is sent until you approve it.
365
+ </p>
366
+ <footer className="rb-actions">
367
+ <button type="button" className="rb-btn" onClick={close}>
368
+ Cancel
369
+ </button>
370
+ <button
371
+ type="button"
372
+ className="rb-btn rb-primary"
373
+ data-testid="report-bug-preview"
374
+ disabled={!canPreview}
375
+ onClick={() => setStep('preview')}
376
+ >
377
+ Review &amp; attach
378
+ </button>
379
+ </footer>
380
+ </>
381
+ )}
382
+
383
+ {step === 'preview' && (
384
+ <>
385
+ <div className="rb-section">
386
+ <div className="rb-section-hd">
387
+ <span>Screenshots ({shots.length})</span>
388
+ {shots.length > 0 && (
389
+ <button
390
+ type="button"
391
+ className={`rb-link${redacting ? ' is-on' : ''}`}
392
+ data-testid="report-bug-redact"
393
+ onClick={() => setRedacting((v) => !v)}
394
+ >
395
+ {redacting ? 'Done redacting' : 'Redact…'}
396
+ </button>
397
+ )}
398
+ </div>
399
+ {shots.length === 0 ? (
400
+ <p className="rb-hint">
401
+ No screenshot attached. Paste one (⌘V) to add it — up to {MAX_SHOTS}.
402
+ </p>
403
+ ) : (
404
+ <>
405
+ {redacting && (
406
+ <p className="rb-hint">Drag over anything private — it ships as solid black.</p>
407
+ )}
408
+ <div className="rb-shots">
409
+ {shots.map((shot, i) => (
410
+ <ShotTile
411
+ key={shot.url}
412
+ shot={shot}
413
+ redacting={redacting}
414
+ onRects={(rects) =>
415
+ setShots((prev) => prev.map((s, j) => (j === i ? { ...s, rects } : s)))
416
+ }
417
+ onRemove={() => {
418
+ URL.revokeObjectURL(shot.url);
419
+ setShots((prev) => prev.filter((_, j) => j !== i));
420
+ }}
421
+ />
422
+ ))}
423
+ </div>
424
+ {shots.length < MAX_SHOTS && (
425
+ <p className="rb-hint">Paste (⌘V) to attach another screenshot.</p>
426
+ )}
427
+ </>
428
+ )}
429
+ </div>
430
+
431
+ <div className="rb-section">
432
+ <div className="rb-section-hd">
433
+ <span>Included with the report</span>
434
+ </div>
435
+ <label className="rb-check">
436
+ <input type="checkbox" checked disabled />
437
+ <span>
438
+ Maude {bundle?.app?.maudeVersion ?? '…'} · {bundle?.app?.platform ?? '…'}{' '}
439
+ {bundle?.app?.arch ?? ''} · {isNativeApp() ? 'desktop app' : 'browser'}
440
+ </span>
441
+ </label>
442
+ {bundle?.context?.activeCanvas && (
443
+ <label className="rb-check">
444
+ <input
445
+ type="checkbox"
446
+ checked={consent.activeCanvas}
447
+ onChange={(e) => setConsent({ ...consent, activeCanvas: e.target.checked })}
448
+ />
449
+ <span>
450
+ Active canvas path — <code>{bundle.context.activeCanvas}</code>
451
+ </span>
452
+ </label>
453
+ )}
454
+ {bundle?.context?.projectName && (
455
+ <label className="rb-check">
456
+ <input
457
+ type="checkbox"
458
+ checked={consent.projectName}
459
+ onChange={(e) => setConsent({ ...consent, projectName: e.target.checked })}
460
+ />
461
+ <span>
462
+ Project name — <code>{bundle.context.projectName}</code>
463
+ </span>
464
+ </label>
465
+ )}
466
+ {bundle?.logs?.serverLogTail ? (
467
+ <label className="rb-check">
468
+ <input
469
+ type="checkbox"
470
+ data-testid="report-bug-consent-logs"
471
+ checked={consent.logTail}
472
+ onChange={(e) => setConsent({ ...consent, logTail: e.target.checked })}
473
+ />
474
+ <span>
475
+ Recent server log ({bundle.logs.serverLogTail.split('\n').length} lines,
476
+ paths &amp; secrets scrubbed) —{' '}
477
+ <details className="rb-details">
478
+ <summary>view</summary>
479
+ <pre className="rb-log">{bundle.logs.serverLogTail}</pre>
480
+ </details>
481
+ </span>
482
+ </label>
483
+ ) : null}
484
+ {isNativeApp() && crashLogs.length > 0 && (
485
+ <label className="rb-check">
486
+ <input
487
+ type="checkbox"
488
+ checked={consent.crashLogs}
489
+ onChange={(e) => setConsent({ ...consent, crashLogs: e.target.checked })}
490
+ />
491
+ <span>
492
+ Crash logs ({crashLogs.length}) — <code>{crashLogs.map((l) => l.name).join(', ')}</code>
493
+ </span>
494
+ </label>
495
+ )}
496
+ </div>
497
+
498
+ <p className="rb-hint">
499
+ Your description and the system info above become a{' '}
500
+ <strong>public GitHub issue</strong>; screenshots and logs go to a private
501
+ repository only maintainers can open.
502
+ </p>
503
+ <footer className="rb-actions">
504
+ <button type="button" className="rb-btn" onClick={() => setStep('describe')}>
505
+ Back
506
+ </button>
507
+ <button
508
+ type="button"
509
+ className="rb-btn rb-primary"
510
+ data-testid="report-bug-send"
511
+ onClick={send}
512
+ >
513
+ Send report
514
+ </button>
515
+ </footer>
516
+ </>
517
+ )}
518
+
519
+ {step === 'sending' && <p className="rb-hint">Sending your report…</p>}
520
+
521
+ {step === 'done' && (
522
+ <>
523
+ {result?.issueUrl ? (
524
+ <p>
525
+ Thank you — your report is filed as{' '}
526
+ <button
527
+ type="button"
528
+ className="rb-link"
529
+ data-testid="report-bug-issue-link"
530
+ onClick={() => {
531
+ if (isNativeApp()) {
532
+ openGitHubUrl(result.issueUrl).catch(() =>
533
+ navigator.clipboard?.writeText(result.issueUrl)
534
+ );
535
+ } else {
536
+ window.open(result.issueUrl, '_blank', 'noopener');
537
+ }
538
+ }}
539
+ >
540
+ report #{result.issueNumber}
541
+ </button>
542
+ {' '}
543
+ on the public Maude issue tracker. Screenshots and logs stay in a private
544
+ repository only maintainers can open — the issue links to them.
545
+ </p>
546
+ ) : (
547
+ <p>
548
+ The bundle is saved locally
549
+ {result?.fallbackDir ? (
550
+ <>
551
+ {' '}
552
+ at <code>{result.fallbackDir}</code>
553
+ </>
554
+ ) : null}{' '}
555
+ and a prefilled GitHub issue was opened — attach the bundle files there.
556
+ </p>
557
+ )}
558
+ <footer className="rb-actions">
559
+ <button type="button" className="rb-btn rb-primary" onClick={close}>
560
+ Done
561
+ </button>
562
+ </footer>
563
+ </>
564
+ )}
565
+
566
+ {step === 'fallback' && (
567
+ <>
568
+ <p className="rb-error" data-testid="report-bug-error">
569
+ {error}
570
+ </p>
571
+ <p className="rb-hint">
572
+ You can save the report locally instead and file it on GitHub yourself — the
573
+ prefilled issue carries the text; screenshots stay on this machine unless you
574
+ attach them by hand.
575
+ </p>
576
+ <footer className="rb-actions">
577
+ <button type="button" className="rb-btn" onClick={() => setStep('preview')}>
578
+ Back
579
+ </button>
580
+ <button type="button" className="rb-btn rb-primary" onClick={saveFallback}>
581
+ Save locally &amp; open GitHub
582
+ </button>
583
+ </footer>
584
+ </>
585
+ )}
586
+ </div>
587
+ </div>
588
+ </div>
589
+ );
590
+ }