@1agh/maude 0.50.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.
@@ -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
+ }
@@ -1993,6 +1993,8 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1993
1993
  .gi-rail { position: relative; flex: 0 0 auto; margin-top: auto; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); display: flex; flex-direction: column; gap: var(--space-2); }
1994
1994
  .gi-rail-hint { font-size: var(--type-xs); color: var(--fg-2); padding: var(--space-1) var(--space-1); }
1995
1995
  .gi-rail-signin { width: 100%; justify-content: center; }
1996
+ .gi-rail-confirm { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); }
1997
+ .gi-rail-confirm .gi-rail-hint { flex: 1 1 100%; padding: 0; }
1996
1998
  .gi-rail-err { font-size: var(--type-xs); color: var(--status-error); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1997
1999
  .gi-avatar { border-radius: var(--radius-pill); display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; color: var(--accent-fg); background: var(--accent); flex: 0 0 auto; }
1998
2000
  .gi-rail-account { display: flex; align-items: center; gap: var(--space-2); width: 100%; padding: var(--space-1) var(--space-2); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--fg-0); cursor: pointer; text-align: left; }
@@ -1875,3 +1875,87 @@
1875
1875
  flex-direction: column;
1876
1876
  gap: 6px;
1877
1877
  }
1878
+
1879
+ /* ----- Report-a-Bug dialog (feature-bug-report-button) -----
1880
+ Reuses the help-modal shell; rb-* covers the consent checklist, screenshot
1881
+ tiles, and the redaction overlay. Same DS voice: hard edges, mono, tokens. */
1882
+ .rb-modal { width: min(640px, 100%); }
1883
+ .rb-body { display: flex; flex-direction: column; gap: var(--u-s-3); }
1884
+ .rb-label { font-weight: 700; color: var(--u-fg-0); }
1885
+ .rb-desc {
1886
+ width: 100%;
1887
+ resize: vertical;
1888
+ background: var(--u-bg-0);
1889
+ border: 1px solid var(--u-border);
1890
+ color: var(--u-fg-0);
1891
+ font-family: var(--u-font-mono);
1892
+ font-size: var(--type-xs);
1893
+ padding: var(--u-s-2);
1894
+ }
1895
+ .rb-desc:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
1896
+ .rb-hint { color: var(--u-fg-2); }
1897
+ .rb-error { color: var(--u-danger, #c0392b); }
1898
+ .rb-section { display: flex; flex-direction: column; gap: var(--u-s-1); }
1899
+ .rb-section-hd {
1900
+ display: flex;
1901
+ align-items: center;
1902
+ justify-content: space-between;
1903
+ font-weight: 700;
1904
+ color: var(--u-fg-0);
1905
+ text-transform: uppercase;
1906
+ letter-spacing: var(--tracking-sku);
1907
+ font-size: 10px;
1908
+ }
1909
+ .rb-check { display: flex; gap: var(--u-s-2); align-items: baseline; cursor: pointer; }
1910
+ .rb-check input { accent-color: var(--u-accent, currentColor); }
1911
+ .rb-check code { color: var(--u-fg-0); }
1912
+ .rb-details summary { cursor: pointer; color: var(--u-fg-2); }
1913
+ .rb-log {
1914
+ max-height: 160px;
1915
+ overflow: auto;
1916
+ background: var(--u-bg-0);
1917
+ border: 1px solid var(--u-border);
1918
+ padding: var(--u-s-2);
1919
+ white-space: pre-wrap;
1920
+ word-break: break-all;
1921
+ }
1922
+ .rb-shots { display: flex; flex-wrap: wrap; gap: var(--u-s-2); }
1923
+ .rb-shot { display: flex; flex-direction: column; gap: 4px; }
1924
+ .rb-shot-box {
1925
+ position: relative;
1926
+ max-width: 260px;
1927
+ border: 1px solid var(--u-border);
1928
+ background: var(--u-bg-0);
1929
+ user-select: none;
1930
+ }
1931
+ .rb-shot-box img { display: block; max-width: 100%; height: auto; }
1932
+ .rb-shot-box.is-redacting { cursor: crosshair; }
1933
+ .rb-shot-box.is-redacting img { pointer-events: none; }
1934
+ .rb-redact-rect { position: absolute; background: #000; pointer-events: none; }
1935
+ .rb-shot-actions { display: flex; gap: var(--u-s-2); }
1936
+ .rb-link {
1937
+ background: transparent;
1938
+ border: none;
1939
+ padding: 0;
1940
+ color: var(--u-fg-1);
1941
+ text-decoration: underline;
1942
+ cursor: pointer;
1943
+ font-family: var(--u-font-mono);
1944
+ font-size: var(--type-xs);
1945
+ }
1946
+ .rb-link:hover, .rb-link.is-on { color: var(--u-fg-0); }
1947
+ .rb-actions { display: flex; justify-content: flex-end; gap: var(--u-s-2); margin-top: var(--u-s-2); }
1948
+ .rb-btn {
1949
+ background: var(--u-bg-2);
1950
+ border: 1px solid var(--u-fg-0);
1951
+ color: var(--u-fg-0);
1952
+ font-family: var(--u-font-mono);
1953
+ font-size: var(--type-xs);
1954
+ padding: var(--u-s-1) var(--u-s-3);
1955
+ cursor: pointer;
1956
+ }
1957
+ .rb-btn:hover { background: var(--u-bg-3); }
1958
+ .rb-btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
1959
+ .rb-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
1960
+ .rb-primary { background: var(--u-fg-0); color: var(--u-bg-0); }
1961
+ .rb-primary:hover { background: var(--u-fg-1); }