BREP_app 0.11.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
//! In-app **Submit Bug** report.
//!
//! A toolbar button opens this flow. It captures, WITHOUT the report dialog in
//! the shot:
//!   * a **screenshot** of the whole application (egui UI + the 3D model) as it
//!     looked the instant the button was pressed,
//!   * the current **model** (the `.nbrep` recipe), and
//!   * a user **description** (+ an optional email), and
//!   * the session's **diagnostics** — which renderer is actually in use and
//!     what it is running on, appended to the description (see
//!     `compose_description` for why they travel inside that field),
//! then POSTs them to the public reports endpoint (`next.brep.io/api/report`).
//! ONE code path runs on both native and wasm.
//!
//! ## Screenshot-before-dialog
//! egui/eframe captures a screenshot of the FRAME (the composited surface, so it
//! includes the 3D viewport, which is an `egui_wgpu` paint callback into egui's
//! frame — see [`crate::viewport`]). We must not let the dialog appear in that
//! frame, so the flow is a small state machine:
//!   1. Button click → snapshot the model, send `ViewportCommand::Screenshot`,
//!      enter `Phase::Capturing`. The dialog is NOT drawn while capturing.
//!   2. A later frame delivers `Event::Screenshot`; we encode it to PNG, build a
//!      preview thumbnail, and enter `Phase::Editing` — only NOW is the dialog
//!      drawn, so the captured frame(s) never contain it.
//!   3. Submit builds a small multipart body by hand (no extra deps) and fires
//!      it through `ehttp`; the reply marshals back over an mpsc channel +
//!      `request_repaint`, exactly like [`crate::panels::step_parts`].

#[path = "bug_report_markup.rs"]
mod markup;

use crate::automation::hit_keys::HitKeyDoc;
use crate::diagnostics::Diagnostics;
use std::sync::mpsc::Receiver;

use brep_render::engine_state::EngineState;
use crate::icon_text::IconTextUi as _;
use eframe::egui;

/// The public reports endpoint the button posts to (fronted
/// by next.brep.io). Accepts the multipart fields
/// `description`,`email`,`model`,`screenshot`.
const REPORT_URL: &str = "https://next.brep.io/api/report";

/// The feature name [`crate::offsite`]'s refusal sentence starts with.
const FEATURE: &str = "Bug report";

/// The server's cap on the `description` field, in CHARACTERS
/// (`description.chars().take(MAX_DESC)` in the endpoint's own
/// `routes/reports.rs`). It truncates the TAIL, and the diagnostics block is at
/// the tail — so [`compose_description`] clamps the user's own text to leave
/// room rather than letting a very long description silently cut the
/// diagnostics off.
const MAX_DESC: usize = 20_000;

/// Frames to wait for the screenshot event before giving up and opening the
/// dialog anyway (so a device that never delivers the capture can't hang the
/// flow). ~1s at 60fps; the persistent offscreen 3D means the capture normally
/// lands within a few frames.
const CAPTURE_TIMEOUT_FRAMES: u32 = 60;

/// The report and markup views occupy this fraction of the application window.
const DIALOG_WIDTH_FRACTION: f32 = 0.90;
const DIALOG_HEIGHT_FRACTION: f32 = 0.95;

/// How many rows of text the description shows before it scrolls instead of
/// growing.
const DESCRIPTION_ROWS: usize = 12;

/// The description field's own text margin (egui's default, named so the
/// field's cap can count it).
const DESCRIPTION_MARGIN: egui::Margin = egui::Margin::symmetric(4, 2);

/// The gap above the button row, and between it and the status line.
const FOOTER_GAP: f32 = 10.0;
const STATUS_GAP: f32 = 6.0;

/// Whether the description's Cut / Copy / Paste menu can reach the clipboard.
/// Native eframe turns those requests into the same input as the keyboard
/// shortcuts; the web runner does not handle them at all, and a browser only
/// hands a page the clipboard inside its own copy/paste events — so there the
/// menu says which keys to press instead.
const MENU_REACHES_CLIPBOARD: bool = cfg!(not(target_arch = "wasm32"));

/// Where the flow is between "button pressed" and "dialog closed".
#[derive(Default, PartialEq)]
enum Phase {
    /// Nothing in progress.
    #[default]
    Idle,
    /// Screenshot requested; dialog intentionally hidden so it isn't captured.
    Capturing { frames: u32 },
    /// Screenshot in hand; dialog open, collecting description + email.
    Editing,
    /// Dedicated screenshot editor, with the report form hidden.
    Marking,
    /// POST in flight.
    Sending,
}

pub struct BugReportPanel {
    phase: Phase,
    /// The problem description (required to submit).
    description: String,
    /// Optional reporter email.
    email: String,
    /// A short status / error line under the buttons.
    status: String,
    /// PNG bytes of the pre-dialog screenshot (UI + 3D), including markup.
    screenshot_png: Option<Vec<u8>>,
    /// A preview texture of the screenshot shown in the dialog.
    thumb: Option<egui::TextureHandle>,
    /// Original capture and editable annotations.
    markup: Option<markup::Markup>,
    /// The model (`.nbrep`) snapshotted at button-press time.
    model_json: String,
    /// The session diagnostics block, taken from the app's ONE
    /// [`Diagnostics`] at button-press time — the same text the Info window
    /// shows, so a report can never describe a different machine from the one
    /// the user was reading about.
    diagnostics: String,
    /// The in-flight POST reply channel (drained each frame).
    response_rx: Option<Receiver<Result<(), String>>>,
    /// Per-frame widget rects for the headed verifier (wasm only).
    hits: std::collections::HashMap<String, egui::Rect>,
    /// How the description's scroll area stood on the last drawn frame, for the
    /// verifier: whether a long report overflows the field and where it is
    /// scrolled to.
    description_scroll: DescriptionScroll,
}

/// The description field's scroll extent, in points.
#[derive(Clone, Copy, Default, Debug, PartialEq)]
struct DescriptionScroll {
    /// The height of the text.
    content: f32,
    /// The height of the field that shows it.
    viewport: f32,
    /// How far down the text is scrolled.
    offset: f32,
}

impl BugReportPanel {
    pub fn new() -> Self {
        Self {
            phase: Phase::Idle,
            description: String::new(),
            email: String::new(),
            status: String::new(),
            screenshot_png: None,
            thumb: None,
            markup: None,
            model_json: String::new(),
            diagnostics: String::new(),
            response_rx: None,
            hits: std::collections::HashMap::new(),
            description_scroll: DescriptionScroll::default(),
        }
    }

    /// Toolbar entry point: snapshot the model + the session diagnostics,
    /// request a screenshot of THIS frame (before the dialog exists), and begin
    /// capturing. Ignored if a report flow is already in progress.
    ///
    /// `diagnostics` is the app's ONE instance — the report renders it here
    /// rather than collecting anything of its own, which is what keeps the
    /// submitted text and the Info window's rows the same rows.
    pub fn request(&mut self, ctx: &egui::Context, state: &EngineState, diagnostics: &Diagnostics) {
        if self.phase != Phase::Idle {
            return;
        }
        self.description.clear();
        self.email.clear();
        self.status.clear();
        self.screenshot_png = None;
        self.thumb = None;
        self.markup = None;
        self.response_rx = None;
        // The model can't change while the modal is open, but snapshot it now so
        // the report reflects exactly the state the user was looking at.
        self.model_json = state.history_request_json();
        self.diagnostics = diagnostics.report_text();
        ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
        self.phase = Phase::Capturing { frames: 0 };
        ctx.request_repaint();
    }

    /// Draw + drive the flow. Called once per frame at ctx level (like the file
    /// dialog). Idempotent while `Phase::Idle`.
    pub fn show(&mut self, ctx: &egui::Context, state: &mut EngineState) {
        self.hits.clear();

        self.poll_capture(ctx);
        self.drain_response(state);

        if !matches!(self.phase, Phase::Editing | Phase::Marking | Phase::Sending) {
            return;
        }

        let sending = self.phase == Phase::Sending;
        let mut submit = false;
        let mut cancel = false;
        let marking = self.phase == Phase::Marking;
        let mut finish = false;
        let window = ctx.content_rect();
        let frame = egui::Frame::popup(&ctx.global_style());
        let content_size = egui::vec2(
            window.width() * DIALOG_WIDTH_FRACTION,
            window.height() * DIALOG_HEIGHT_FRACTION,
        ) - frame.total_margin().sum();
        let modal = egui::Modal::new(egui::Id::new("brep-bug-report"))
            .frame(frame)
            .show(ctx, |ui| {
                ui.set_width(content_size.x);
                let content_rect = egui::Rect::from_min_size(ui.min_rect().min, content_size);
                if marking {
                    let finish_btn = ui.button("Finish");
                    self.hit("markup:finish", &finish_btn);
                    finish = finish_btn.clicked();
                    ui.separator();
                    let height =
                        (content_size.y - ui.min_rect().height() - ui.spacing().item_spacing.y)
                            .max(0.0);
                    let budget = egui::Rect::from_min_size(
                        ui.cursor().min,
                        egui::vec2(content_size.x, height),
                    );
                    ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
                        if let (Some(markup), Some(tex)) = (&mut self.markup, &self.thumb) {
                            if let Some(image) = markup.show(ui, tex, &mut self.hits) {
                                self.screenshot_png = encode_png(&image);
                                self.thumb
                                    .as_mut()
                                    .unwrap()
                                    .set(image, egui::TextureOptions::LINEAR);
                            }
                        }
                    });
                    ui.expand_to_include_rect(content_rect);
                    return;
                }
                // `icon_label`, not `heading`: U+1F41E is a catalogued COLOUR icon, so
                // this draws the real artwork inline with the title instead of the
                // font's monochrome outline.
                ui.icon_label(egui::RichText::new("\u{1F41E}  Submit a bug report").heading());
                ui.add_space(4.0);
                ui.label(
                    "Describe what went wrong. Your current model and a screenshot of \
                 the app (UI + 3D view) are attached automatically.",
                );
                ui.add_space(2.0);
                ui.weak("Your report and its screenshot may be shown publicly on the bug list.");
                ui.add_space(8.0);

                // Keep the body at a fixed height within the proportional modal,
                // leaving the footer visible even when the report body scrolls.
                let chrome = ui.min_rect().height();
                let body_height = (content_size.y - chrome - self.footer_height(ui)).max(0.0);
                let budget = egui::Rect::from_min_size(
                    ui.cursor().min,
                    egui::vec2(ui.available_width(), body_height),
                );
                ui.scope_builder(egui::UiBuilder::new().max_rect(budget), |ui| {
                    egui::ScrollArea::vertical()
                        .id_salt("brep-bug-body")
                        .min_scrolled_height(body_height)
                        .max_height(body_height)
                        .auto_shrink([false, false])
                        .show(ui, |ui| self.body(ui));
                });

                ui.add_space(FOOTER_GAP);
                ui.horizontal(|ui| {
                    let can_submit = !self.description.trim().is_empty() && !sending;
                    let label = if sending {
                        "Sending\u{2026}"
                    } else {
                        "Submit report"
                    };
                    let submit_btn = ui.add_enabled(can_submit, egui::Button::new(label));
                    self.hit("submit", &submit_btn);
                    if submit_btn.clicked() {
                        submit = true;
                    }
                    let cancel_btn = ui.add_enabled(!sending, egui::Button::new("Cancel"));
                    self.hit("cancel", &cancel_btn);
                    if cancel_btn.clicked() {
                        cancel = true;
                    }
                });
                // The status line's row is always there, empty or not, so a status
                // appearing ("Submitting…") does not change the dialog's size either.
                ui.add_space(STATUS_GAP);
                ui.weak(if self.status.is_empty() {
                    " "
                } else {
                    self.status.as_str()
                });
                ui.expand_to_include_rect(content_rect);
            });

        if marking {
            if finish || modal.should_close() {
                self.phase = Phase::Editing;
            }
        } else if submit {
            self.send(ctx);
        } else if cancel || (modal.should_close() && !sending) {
            self.reset();
        }
    }

    /// The height [`Self::show`] draws under the body: the gap, the button row
    /// and the status line's row, which is always drawn. Worked out from the
    /// style the footer is drawn with, so the body's budget is right on the
    /// first frame.
    fn footer_height(&self, ui: &egui::Ui) -> f32 {
        let spacing = ui.spacing();
        let buttons = spacing
            .interact_size
            .y
            .max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * spacing.button_padding.y);
        let status = STATUS_GAP
            + ui.text_style_height(&egui::TextStyle::Body)
            + 2.0 * spacing.item_spacing.y;
        FOOTER_GAP + buttons + 2.0 * spacing.item_spacing.y + status
    }

    /// The dialog's scrolling body: the description, the email, the attached
    /// screenshot and the diagnostics.
    fn body(&mut self, ui: &mut egui::Ui) {
        ui.label("What happened?");
        self.description_field(ui);

        ui.add_space(6.0);
        ui.label("Email (optional)");
        let email = ui.add(
            egui::TextEdit::singleline(&mut self.email)
                .desired_width(f32::INFINITY)
                .hint_text("so we can follow up — optional"),
        );
        self.hit("field:email", &email);

        ui.add_space(8.0);
        if let Some(tex) = &self.thumb {
            ui.label("Attached screenshot:");
            ui.add_space(2.0);
            if self.markup.is_some() {
                let button = ui.add_enabled(
                    self.phase != Phase::Sending,
                    egui::Button::new("Mark up screenshot"),
                );
                self.hits
                    .insert("markup:toggle".into(), button.interact_rect);
                if button.clicked() {
                    self.phase = Phase::Marking;
                }
            }
            let size = tex.size_vec2();
            let scale = (ui.available_width() / size.x).min(240.0 / size.y).min(1.0);
            ui.add(egui::Image::new((tex.id(), size * scale)));
        } else {
            ui.weak("(screenshot unavailable — the model + description will still be sent)");
        }

        // What the report will carry about this machine, shown before it is
        // sent rather than attached invisibly. Collapsed: it is for the
        // triager, and the user has already read it in the Info window if
        // they wanted to.
        ui.add_space(8.0);
        let diag = egui::CollapsingHeader::new("Diagnostics attached to this report")
            .id_salt("brep-bug-diagnostics")
            .show(ui, |ui| {
                ui.add(
                    egui::Label::new(egui::RichText::new(&self.diagnostics).monospace().small())
                        .wrap(),
                );
            });
        self.hits.insert(
            "diagnostics".to_string(),
            diag.header_response.interact_rect,
        );
    }

    /// The description. It grows with its text up to [`DESCRIPTION_ROWS`] rows
    /// and then scrolls inside its own bar, so a long report never grows the
    /// dialog; the field follows the caret as the user types.
    ///
    /// A bare scroll area is enough here because the body it sits in was given
    /// an explicit budget: the room it measures is that budget, not the size
    /// the modal happened to have last frame.
    fn description_field(&mut self, ui: &mut egui::Ui) {
        let cap = DESCRIPTION_ROWS as f32 * ui.text_style_height(&egui::TextStyle::Body)
            + DESCRIPTION_MARGIN.sum().y;
        let scroll = egui::ScrollArea::vertical()
            .id_salt("brep-bug-description")
            .max_height(cap)
            .auto_shrink([false, true])
            .show(ui, |ui| {
                ui.add(
                    egui::TextEdit::multiline(&mut self.description)
                        .margin(DESCRIPTION_MARGIN)
                        .desired_rows(5)
                        .desired_width(f32::INFINITY)
                        .hint_text("Steps, what you expected, and what actually happened"),
                )
            });
        self.description_scroll = DescriptionScroll {
            content: scroll.content_size.y,
            viewport: scroll.inner_rect.height(),
            offset: scroll.state.offset.y,
        };
        let field = scroll.inner;
        self.hit("field:description", &field);
        field.context_menu(|ui| self.edit_menu(ui, &field));
    }

    /// The description's right-click menu: Cut, Copy and Paste. An egui text
    /// field has no menu of its own, and right-click → Paste is how a lot of
    /// people paste — on a Mac especially, where the reporter looked for it.
    ///
    /// Each entry asks the platform for exactly what its keyboard shortcut
    /// does, and first hands focus back to the field: clicking the menu took
    /// focus away, and a text field ignores a paste it is not focused for. The
    /// platform delivers the action as input on the NEXT frame, by which time
    /// the field has focus again.
    fn edit_menu(&mut self, ui: &mut egui::Ui, field: &egui::Response) {
        let entries = [
            ("menu:cut", "Cut", egui::ViewportCommand::RequestCut),
            ("menu:copy", "Copy", egui::ViewportCommand::RequestCopy),
            ("menu:paste", "Paste", egui::ViewportCommand::RequestPaste),
        ];
        for (key, label, command) in entries {
            let item = ui.add_enabled(MENU_REACHES_CLIPBOARD, egui::Button::new(label));
            self.hit(key, &item);
            if item.clicked() {
                field.request_focus();
                ui.ctx().send_viewport_cmd(command);
                ui.close();
            }
        }
        if !MENU_REACHES_CLIPBOARD {
            ui.weak("In the browser, use Ctrl+X / C / V (Cmd on a Mac).");
        }
    }

    /// While capturing, look for the delivered screenshot; time out gracefully.
    fn poll_capture(&mut self, ctx: &egui::Context) {
        let frames = match &mut self.phase {
            Phase::Capturing { frames } => {
                *frames += 1;
                *frames
            }
            _ => return,
        };
        // eframe injects `Event::Screenshot` into the frame's raw input once the
        // async framebuffer readback completes. Take the newest one.
        let shot = ctx.input(|i| {
            i.raw.events.iter().rev().find_map(|e| match e {
                egui::Event::Screenshot { image, .. } => Some(image.clone()),
                _ => None,
            })
        });
        if let Some(img) = shot {
            self.screenshot_png = encode_png(&img);
            self.markup = Some(markup::Markup::new((*img).clone()));
            self.thumb = Some(ctx.load_texture(
                "brep-bug-shot",
                (*img).clone(),
                egui::TextureOptions::LINEAR,
            ));
            self.phase = Phase::Editing;
        } else if frames > CAPTURE_TIMEOUT_FRAMES {
            self.status = "(screenshot unavailable)".into();
            self.phase = Phase::Editing;
        } else {
            ctx.request_repaint();
        }
    }

    /// Drain the POST reply: success closes the dialog with a toast; an error
    /// stays open so the user can retry.
    fn drain_response(&mut self, state: &mut EngineState) {
        let Some(rx) = &self.response_rx else { return };
        let Ok(result) = rx.try_recv() else { return };
        self.response_rx = None;
        match result {
            Ok(()) => {
                state.push_notice("Bug report submitted — thank you!".to_string());
                self.reset();
            }
            Err(e) => {
                self.status = crate::offsite::explain(FEATURE, REPORT_URL, format!("Submit failed: {e}"));
                self.phase = Phase::Editing;
            }
        }
    }

    /// Build the multipart body and fire the POST (native + wasm via ehttp).
    fn send(&mut self, ctx: &egui::Context) {
        // On a PLM-hosted app that does not allow the report site, say so
        // instead of letting the browser refuse the request (crate::offsite).
        if let Some(sentence) = crate::offsite::refusal(FEATURE, REPORT_URL) {
            self.status = sentence;
            return;
        }
        self.status = "Submitting\u{2026}".into();
        self.phase = Phase::Sending;

        let (content_type, body) = build_multipart(
            &compose_description(&self.description, &self.diagnostics),
            &self.email,
            &self.model_json,
            self.screenshot_png.as_deref(),
        );
        let mut req = ehttp::Request::post(REPORT_URL, body);
        // `Request::post` sets text/plain; replace it with our multipart type.
        req.headers
            .headers
            .retain(|(k, _)| !k.eq_ignore_ascii_case("content-type"));
        req.headers.headers.push(("Content-Type".to_string(), content_type));

        let (tx, rx) = std::sync::mpsc::channel();
        let ctx = ctx.clone();
        ehttp::fetch(req, move |result| {
            let out = match result {
                Ok(resp) if resp.ok => Ok(()),
                Ok(resp) => Err(format!("HTTP {} {}", resp.status, resp.status_text)),
                Err(err) => Err(err),
            };
            let _ = tx.send(out);
            ctx.request_repaint();
        });
        self.response_rx = Some(rx);
    }

    /// Back to idle, dropping the screenshot + preview texture.
    fn reset(&mut self) {
        self.phase = Phase::Idle;
        self.description.clear();
        self.email.clear();
        self.status.clear();
        self.diagnostics.clear();
        self.screenshot_png = None;
        self.thumb = None;
        self.markup = None;
        self.response_rx = None;
        self.description_scroll = DescriptionScroll::default();
    }

    /// Record a widget's screen rect for the verifier — its VISIBLE part: the
    /// body and the description both scroll, and a click at the centre of a
    /// rect that runs past its scroll area's clip lands on nothing.
    fn hit(&mut self, key: &str, resp: &egui::Response) {
        self.hits.insert(key.to_string(), resp.interact_rect);
    }

    /// Logical state for the headed verifier: which phase + whether a shot was
    /// captured.
    pub fn state_json(&self) -> String {
        let phase = match self.phase {
            Phase::Idle => "idle",
            Phase::Capturing { .. } => "capturing",
            Phase::Editing => "editing",
            Phase::Marking => "marking",
            Phase::Sending => "sending",
        };
        serde_json::json!({
            "phase": phase,
            "hasScreenshot": self.screenshot_png.is_some(),
            "annotationCount": self.markup.as_ref().map_or(0, markup::Markup::count),
            "diagnostics": self.diagnostics,
            "status": self.status,
            "descriptionScroll": {
                "content": self.description_scroll.content,
                "viewport": self.description_scroll.viewport,
                "offset": self.description_scroll.offset,
            },
        })
        .to_string()
    }

    /// The published widget hit-rects (egui points) for the headed verifier.
    pub fn hits_json(&self) -> String {
        let map: serde_json::Map<String, serde_json::Value> = self
            .hits
            .iter()
            .map(|(k, r)| {
                (
                    k.clone(),
                    serde_json::json!([r.center().x, r.center().y, r.width(), r.height()]),
                )
            })
            .collect();
        serde_json::Value::Object(map).to_string()
    }
}

/// Encode an egui `ColorImage` (the screenshot) to PNG bytes via the pure-Rust
/// `image` crate (wasm-safe). `None` on a zero-size image or encode error.
fn encode_png(color: &egui::ColorImage) -> Option<Vec<u8>> {
    use image::ImageEncoder;
    let [w, h] = color.size;
    if w == 0 || h == 0 {
        return None;
    }
    let mut rgba = Vec::with_capacity(w * h * 4);
    for px in &color.pixels {
        // Straight (un-premultiplied) sRGBA, matching a normal PNG.
        rgba.extend_from_slice(&px.to_srgba_unmultiplied());
    }
    let mut png = Vec::new();
    image::codecs::png::PngEncoder::new(&mut png)
        .write_image(&rgba, w as u32, h as u32, image::ExtendedColorType::Rgba8)
        .ok()?;
    Some(png)
}

/// The `description` the report actually submits: what the user typed, then the
/// diagnostics block.
///
/// **Why inside `description` and not its own part.** The endpoint
/// (`next.brep.io/api/report`) parses multipart into a fixed four-field record
/// and its match ends `_ => {}` — an unknown part is accepted and silently
/// DROPPED, never rejected. A `diagnostics` part would therefore submit cleanly
/// and arrive nowhere, which is the worst of both: a client that looks like it
/// reports the renderer and a server that never stores it. `description` is
/// stored verbatim in `report.json` and is what a triager reads first, so the
/// block goes there until the server grows a field of its own.
///
/// APPENDED, never prepended: the server's push notification and its public
/// report list both summarise a report by its FIRST LINE, and a list where every
/// row reads "--- diagnostics ---" tells a reader nothing.
///
/// The user's own text is clamped so the block survives the server's
/// [`MAX_DESC`] tail truncation. A description long enough to hit that cap has
/// already said what it has to say; the diagnostics are the part that cannot be
/// re-derived later.
fn compose_description(description: &str, diagnostics: &str) -> String {
    if diagnostics.is_empty() {
        return description.chars().take(MAX_DESC).collect();
    }
    const SEPARATOR: &str = "\n\n";
    // `chars`, matching the server's own `chars().take(MAX_DESC)`.
    let block_len = diagnostics.chars().count() + SEPARATOR.chars().count();
    let room = MAX_DESC.saturating_sub(block_len);
    let user: String = description.chars().take(room).collect();
    format!("{user}{SEPARATOR}{diagnostics}")
}

/// Hand-build a `multipart/form-data` body (avoids ehttp's `multipart` feature,
/// which pulls `rand`→`getrandom` and would need the wasm `js` feature). The
/// boundary carries a distinctive ASCII prefix plus the payload lengths so it
/// can't collide with the (text) model JSON or the PNG bytes.
fn build_multipart(
    description: &str,
    email: &str,
    model: &str,
    screenshot: Option<&[u8]>,
) -> (String, Vec<u8>) {
    let boundary = format!(
        "----BREPBugReport{:x}x{:x}Boundary",
        model.len(),
        screenshot.map(|s| s.len()).unwrap_or(0)
    );
    let mut body = Vec::new();
    push_text_field(&mut body, &boundary, "description", description);
    push_text_field(&mut body, &boundary, "email", email);
    push_file_field(
        &mut body,
        &boundary,
        "model",
        "model.nbrep",
        "application/json",
        model.as_bytes(),
    );
    if let Some(png) = screenshot {
        push_file_field(&mut body, &boundary, "screenshot", "screenshot.png", "image/png", png);
    }
    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
    (format!("multipart/form-data; boundary={boundary}"), body)
}

fn push_text_field(body: &mut Vec<u8>, boundary: &str, name: &str, value: &str) {
    body.extend_from_slice(
        format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n")
            .as_bytes(),
    );
    body.extend_from_slice(value.as_bytes());
    body.extend_from_slice(b"\r\n");
}

fn push_file_field(
    body: &mut Vec<u8>,
    boundary: &str,
    name: &str,
    filename: &str,
    content_type: &str,
    bytes: &[u8],
) {
    body.extend_from_slice(
        format!(
            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; \
             filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n"
        )
        .as_bytes(),
    );
    body.extend_from_slice(bytes);
    body.extend_from_slice(b"\r\n");
}

/// The hit keys this panel publishes (see `automation::hit_keys`).
pub static HIT_KEYS: &[HitKeyDoc] = &[
    HitKeyDoc { panel: "bug", prefix: "markup:", meaning: "screenshot markup toggle, markup:tool (the pen/arrow/rectangle dropdown; its open entries markup:tool#pen, #arrow, #rectangle), image drawing area, undo and clear", command: None },
    HitKeyDoc { panel: "bug", prefix: "field:description", meaning: "the description field (its visible part; right-click opens its Cut / Copy / Paste menu)", command: None },
    HitKeyDoc { panel: "bug", prefix: "menu:cut", meaning: "cut the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
    HitKeyDoc { panel: "bug", prefix: "menu:copy", meaning: "copy the description's selection, from its right-click menu (native; disabled in the browser)", command: None },
    HitKeyDoc { panel: "bug", prefix: "menu:paste", meaning: "paste the clipboard into the description, from its right-click menu (native; disabled in the browser)", command: None },
    HitKeyDoc { panel: "bug", prefix: "field:email", meaning: "the email field", command: None },
    HitKeyDoc { panel: "bug", prefix: "diagnostics", meaning: "expand the diagnostics block the report will carry (the `diagnostics` command returns the same rows, but expanding a header is not what it does)", command: None },
    HitKeyDoc { panel: "bug", prefix: "submit", meaning: "submit the report", command: None },
    HitKeyDoc { panel: "bug", prefix: "cancel", meaning: "close the report", command: None },
];