Просмотр исходного кода

Move the bug-report trigger out of the contended corner (#2750)

The floating disc is pinned bottom-right, which is where most controls
live — it covered ~83% of the Profiles scroll-to-top button at the same
z-index, and being viewport-fixed it also sits on card action buttons
that scroll under it. Below the sidebar-compact breakpoint the trigger
moves into the top bar; at 1144px and up nothing changes.

Not a hide switch: the bubble is the only entry to the report form, and
that form runs the printer diagnostic, the log scan and the debug
capture. Hiding it yields reports with nothing attached.

The panel stays at the Layout root — the header is a fixed z-40
stacking context and would bury a nested z-50 panel under every modal.
Also fixes the panel hanging 16px off-screen on phones: w-full resolves
against the viewport, so right-4 pushed its left edge negative.
maziggy 1 месяц назад
Родитель
Сommit
8dde48587e

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests.
 - **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
 - **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.
 - **Chamber temperature can now be set up to 65 °C, not 60 (reported on Discord)** — Every field in Bambuddy that takes a chamber target stopped at 60 °C: the per-filament chamber map and the per-print chamber override in **Preheat & Heat Soak**, the chamber quick-select presets, and the chamber temperature control on the printer card. 60 is the ceiling for the X1E, which was the only heated-chamber model when that limit was written; the H2 series (H2C, H2D, H2D Pro, H2S) and the X2D heat to 65, so the top of their range was simply unreachable — an ABS or PA profile calling for 65 had to be run at 60. The ceiling is now 65 everywhere, held in one constant on each side rather than repeated as a literal at every call site, so the four surfaces cannot drift apart again. X1E owners are unaffected: its firmware clamps a higher request to its own maximum. Wiki updated. Covered by backend tests.

+ 206 - 0
frontend/src/__tests__/components/BugReportTriggerPlacement.test.tsx

@@ -0,0 +1,206 @@
+/**
+ * Where the bug-report trigger lives, and what shape its panel takes (#2750,
+ * reporter @goodjaltman).
+ *
+ * The floating disc is pinned to the bottom-right corner, which is the most
+ * contended region in the app — the Profiles scroll-to-top FAB, the floating
+ * camera window, the Group Edit save bar, the bulk-selection toolbars and the
+ * per-card action buttons on File Manager and Archives all sit there, and a
+ * `fixed` disc covers whichever happens to be underneath at the time. Below the
+ * sidebar-compact breakpoint the trigger moves into the compact header instead,
+ * which frees the corner outright.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { waitFor, fireEvent, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { Layout } from '../../components/Layout';
+import { BugReportBubble } from '../../components/BugReportBubble';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+/** The always-false stub from setup.ts, restored after each test. */
+const defaultMatchMedia = (query: string) => ({
+  matches: false,
+  media: query,
+  onchange: null,
+  addListener: () => {},
+  removeListener: () => {},
+  addEventListener: () => {},
+  removeEventListener: () => {},
+  dispatchEvent: () => true,
+});
+
+/**
+ * Pretend the viewport is ``width`` px wide.
+ *
+ * Both breakpoint hooks read `window.innerWidth` for their initial value and
+ * `matchMedia('(max-width: Npx)')` thereafter, so the stub has to answer the
+ * query rather than return a fixed boolean — useIsMobile (768) and
+ * useIsSidebarCompact (1144) ask different questions and a flat `true` would
+ * conflate them.
+ */
+function stubViewport(width: number) {
+  Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    configurable: true,
+    value: (query: string) => {
+      const max = /max-width:\s*(\d+)px/.exec(query);
+      return { ...defaultMatchMedia(query), matches: max ? width <= Number(max[1]) : false };
+    },
+  });
+}
+
+function setupLayoutHandlers() {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE' })),
+    http.get('/api/v1/version', () => HttpResponse.json({ version: '0.1.6', build: 'test' })),
+    http.get('/api/v1/settings/', () =>
+      HttpResponse.json({ check_updates: false, check_printer_firmware: false, auto_archive: true }),
+    ),
+    http.get('/api/v1/external-links/', () => HttpResponse.json([])),
+    http.get('/api/v1/smart-plugs/', () => HttpResponse.json([])),
+    http.get('/api/v1/support/debug-logging', () => HttpResponse.json({ enabled: false })),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/pending-uploads/count', () => HttpResponse.json({ count: 0 })),
+    http.get('/api/v1/updates/check', () => HttpResponse.json({ update_available: false })),
+    http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+    http.get('/api/v1/printers/developer-mode-warnings', () => HttpResponse.json([])),
+    http.get('/api/v1/system/health', () => HttpResponse.json({ findings: [] })),
+  );
+}
+
+/** The floating disc, identified by the shape only it has. */
+const floatingDisc = () =>
+  Array.from(document.querySelectorAll('button')).find(
+    (b) => b.className.includes('rounded-full') && b.className.includes('bottom-4'),
+  );
+
+describe('bug-report trigger placement', () => {
+  beforeEach(() => {
+    vi.mocked(localStorage.getItem).mockReturnValue(null);
+    setupLayoutHandlers();
+  });
+
+  afterEach(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      configurable: true,
+      value: defaultMatchMedia,
+    });
+  });
+
+  it('puts the trigger in the compact header and frees the corner below 1144px', async () => {
+    stubViewport(390);
+    render(<Layout />);
+
+    await waitFor(() => expect(document.querySelector('header')).toBeInTheDocument());
+    const header = document.querySelector('header')!;
+    expect(within(header).getByRole('button', { name: /report a bug|bug/i })).toBeInTheDocument();
+    expect(floatingDisc()).toBeUndefined();
+  });
+
+  it('keeps the floating disc when the sidebar is not compact', async () => {
+    stubViewport(1440);
+    render(<Layout />);
+
+    await waitFor(() => expect(floatingDisc()).toBeDefined());
+    // No compact header exists at this width, so there is nowhere else for it.
+    expect(document.querySelector('header')).toBeNull();
+  });
+
+  it('opens the same panel from the header trigger', async () => {
+    stubViewport(390);
+    render(<Layout />);
+
+    await waitFor(() => expect(document.querySelector('header')).toBeInTheDocument());
+    const header = document.querySelector('header')!;
+    fireEvent.click(within(header).getByRole('button', { name: /report a bug|bug/i }));
+
+    await waitFor(() => expect(document.getElementById('bug-report-modal')).toBeInTheDocument());
+  });
+});
+
+describe('bug-report panel geometry', () => {
+  afterEach(() => {
+    Object.defineProperty(window, 'matchMedia', {
+      writable: true,
+      configurable: true,
+      value: defaultMatchMedia,
+    });
+  });
+
+  it('is a full-width bottom sheet on a phone', async () => {
+    // The regression: `fixed ... right-4 w-full max-w-md` resolves w-full
+    // against the viewport, so on a 390px screen the panel was 390px wide and
+    // then inset 16px from the right — putting its left edge at -16px and
+    // cutting a strip of the form off-screen. max-w-md hid this above ~464px.
+    stubViewport(390);
+    render(<BugReportBubble open onOpenChange={() => {}} />);
+
+    const panel = await waitFor(() => {
+      const el = document.getElementById('bug-report-modal');
+      expect(el).toBeInTheDocument();
+      return el!;
+    });
+    expect(panel.className).toContain('inset-x-0');
+    expect(panel.className).not.toContain('right-4');
+    expect(panel.className).not.toContain('w-full');
+  });
+
+  it('anchors under the header when the trigger lives there (tablet band)', async () => {
+    // 768-1143px: past the bottom-sheet breakpoint but the trigger is in the
+    // compact header, so the panel must not open in a corner the user never
+    // touched.
+    stubViewport(900);
+    render(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
+
+    const panel = await waitFor(() => {
+      const el = document.getElementById('bug-report-modal');
+      expect(el).toBeInTheDocument();
+      return el!;
+    });
+    expect(panel.className).toContain('top-16');
+    expect(panel.className).not.toContain('bottom-20');
+  });
+
+  it('keeps the anchored card on desktop', async () => {
+    stubViewport(1440);
+    render(<BugReportBubble open onOpenChange={() => {}} />);
+
+    const panel = await waitFor(() => {
+      const el = document.getElementById('bug-report-modal');
+      expect(el).toBeInTheDocument();
+      return el!;
+    });
+    expect(panel.className).toContain('right-4');
+    expect(panel.className).toContain('max-w-md');
+  });
+});
+
+describe('bug-report form reset', () => {
+  it('clears a half-filled form when reopened from a controlled trigger', async () => {
+    // The reset used to live in the floating disc's click handler. With the
+    // header trigger only flipping a controlled flag, that would have left the
+    // previous draft sitting there for compact layouts.
+    const user = userEvent.setup();
+    const { rerender } = render(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
+
+    const textarea = await waitFor(() => document.querySelector('textarea')!);
+    await user.type(textarea, 'half-written report');
+    expect(textarea).toHaveValue('half-written report');
+
+    rerender(<BugReportBubble showTrigger={false} open={false} onOpenChange={() => {}} />);
+    rerender(<BugReportBubble showTrigger={false} open onOpenChange={() => {}} />);
+
+    await waitFor(() => expect(document.querySelector('textarea')).toHaveValue(''));
+  });
+
+  it('renders no floating disc when the trigger is hosted elsewhere', () => {
+    render(<BugReportBubble showTrigger={false} />);
+    expect(floatingDisc()).toBeUndefined();
+  });
+});

+ 73 - 17
frontend/src/components/BugReportBubble.tsx

@@ -6,6 +6,7 @@ import { api, bugReportApi, type PrinterDiagnosticResult } from '../api/client';
 import { DiagnosticChecklist } from './ConnectionDiagnostic';
 import { SystemHealthPanel } from './SystemHealthPanel';
 import { Collapsible } from './Collapsible';
+import { useIsMobile } from '../hooks/useIsMobile';
 
 type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
 
@@ -47,9 +48,36 @@ function formatElapsed(seconds: number): string {
   return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
 }
 
-export function BugReportBubble() {
+interface BugReportBubbleProps {
+  /**
+   * Render the floating disc in the bottom-right corner. False when the
+   * trigger lives somewhere else — the compact header does this (#2750), so
+   * the panel still mounts here while the button that opens it sits in the
+   * header. The panel deliberately stays at the Layout root rather than
+   * moving into the header with its button: the header is a ``fixed z-40``
+   * element and therefore its own stacking context, so a ``z-50`` panel
+   * nested inside it would be capped at the header's level and end up
+   * underneath every ordinary z-50 modal in the app.
+   */
+  showTrigger?: boolean;
+  /** Controlled open state. Falls back to internal state when omitted. */
+  open?: boolean;
+  onOpenChange?: (open: boolean) => void;
+}
+
+export function BugReportBubble({ showTrigger = true, open, onOpenChange }: BugReportBubbleProps = {}) {
   const { t } = useTranslation();
-  const [isOpen, setIsOpen] = useState(false);
+  const isMobile = useIsMobile();
+  const [internalOpen, setInternalOpen] = useState(false);
+  const isControlled = open !== undefined;
+  const isOpen = isControlled ? open : internalOpen;
+  const setIsOpen = useCallback(
+    (next: boolean) => {
+      if (!isControlled) setInternalOpen(next);
+      onOpenChange?.(next);
+    },
+    [isControlled, onOpenChange],
+  );
   const [viewState, setViewState] = useState<ViewState>('form');
   const [description, setDescription] = useState('');
   const [email, setEmail] = useState('');
@@ -109,8 +137,12 @@ export function BugReportBubble() {
     return () => clearTimeout(timer);
   }, [viewState, elapsedSeconds]);
 
-  const handleOpen = () => {
-    setIsOpen(true);
+  // Reset on open rather than in the click handler: the panel now has two
+  // possible triggers (the floating disc here, and the compact header's button
+  // which only flips the controlled flag), and a stale half-filled form
+  // reappearing for one of them would be a nasty little inconsistency.
+  useEffect(() => {
+    if (!isOpen) return;
     setViewState('form');
     setDescription('');
     setEmail('');
@@ -120,7 +152,9 @@ export function BugReportBubble() {
     setErrorMessage('');
     setElapsedSeconds(0);
     setWasDebug(false);
-  };
+  }, [isOpen]);
+
+  const handleOpen = () => setIsOpen(true);
 
   const handleClose = () => {
     setIsOpen(false);
@@ -216,25 +250,47 @@ export function BugReportBubble() {
 
   return (
     <>
-      {/* Floating bubble */}
-      <button
-        onClick={handleOpen}
-        className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
-        title={t('bugReport.title')}
-      >
-        <Bug className="w-5 h-5" />
-      </button>
-
-      {/* Slide-in panel anchored to bottom-right */}
+      {/* Floating bubble. Absent below the sidebar-compact breakpoint, where
+          the compact header carries the trigger instead — see Layout. */}
+      {showTrigger && (
+        <button
+          onClick={handleOpen}
+          className="fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
+          title={t('bugReport.title')}
+        >
+          <Bug className="w-5 h-5" />
+        </button>
+      )}
+
+      {/* Slide-in panel anchored to bottom-right; a bottom sheet on phones.
+          The desktop geometry cannot be reused there: `w-full` resolves against
+          the viewport for a fixed element, so on a 375px screen the panel was
+          375px wide and then pushed 16px in from the right, putting its left
+          edge at -16px and cutting a strip of the form off-screen. `max-w-md`
+          hid this on anything above ~464px wide. */}
       {isOpen && (
         <div
           id="bug-report-modal"
-          className="fixed bottom-20 right-4 z-50 w-full max-w-md"
+          className={
+            isMobile
+              ? 'fixed inset-x-0 bottom-0 z-50'
+              : showTrigger
+                ? 'fixed bottom-20 right-4 z-50 w-full max-w-md'
+                // Trigger is in the compact header, so anchor under it rather
+                // than to a corner the user did not touch. Only reachable
+                // between the mobile and sidebar-compact breakpoints — below
+                // that it is a bottom sheet, above it the disc is back.
+                : 'fixed top-16 right-4 z-50 w-full max-w-md'
+          }
           onPaste={handlePaste}
         >
           <div
             ref={modalRef}
-            className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 max-h-[80vh] overflow-y-auto"
+            className={`bg-white dark:bg-gray-800 shadow-2xl border border-gray-200 dark:border-gray-700 overflow-y-auto ${
+              isMobile
+                ? 'rounded-t-2xl max-h-[85vh] pb-[env(safe-area-inset-bottom)]'
+                : 'rounded-lg max-h-[80vh]'
+            }`}
           >
             {/* Header */}
             <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">

+ 30 - 1
frontend/src/components/Layout.tsx

@@ -73,6 +73,17 @@ export function Layout() {
   const { t } = useTranslation();
   const isSidebarCompact = useIsSidebarCompact();
 
+  // Bug-report panel state lives here because the trigger moves (#2750,
+  // reporter @goodjaltman). Below the sidebar-compact breakpoint the floating
+  // disc is replaced by a button in the compact header: the bottom-right corner
+  // is the most contended region in the app — the Profiles scroll-to-top FAB,
+  // the floating camera window and its resize handle, the Group Edit save bar,
+  // the bulk-selection toolbars, and the File Manager / Archives per-card
+  // action buttons all live there — and a fixed 48px disc sits on top of
+  // whichever of them happens to be underneath. Moving out of the corner is
+  // the only fix that covers in-flow content as well as fixed overlays.
+  const [bugReportOpen, setBugReportOpen] = useState(false);
+
   // Theme toggle: mode → icon and tooltip
   const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
   const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
@@ -494,6 +505,15 @@ export function Layout() {
             alt="Bambuddy"
             className="h-8 ml-3"
           />
+          {/* Bug report — the compact-layout home of the floating bubble. */}
+          <button
+            onClick={() => setBugReportOpen(true)}
+            className="ml-auto p-2 -mr-2 rounded-lg text-red-500 hover:bg-bambu-dark-tertiary transition-colors"
+            title={t('bugReport.title')}
+            aria-label={t('bugReport.title')}
+          >
+            <Bug className="w-5 h-5" />
+          </button>
         </header>
       )}
 
@@ -1108,7 +1128,16 @@ export function Layout() {
           </Card>
         </div>
       )}
-      <BugReportBubble />
+      {/* The panel always mounts here, at the Layout root. It must not move
+          into the header alongside its compact-layout trigger: the header is
+          `fixed z-40` and so its own stacking context, which would cap the
+          z-50 panel at the header's level and bury it under every ordinary
+          modal in the app. */}
+      <BugReportBubble
+        showTrigger={!isSidebarCompact}
+        open={bugReportOpen}
+        onOpenChange={setBugReportOpen}
+      />
     </div>
   );
 }

+ 4 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -378,10 +378,13 @@ function ScrollToTop() {
 
   if (!isVisible) return null;
 
+  // bottom-24, not bottom-6: at 24px this sat under the bug-report bubble
+  // (16-64px from both edges), covering ~40x40 of its 44x44. Both are z-40, so
+  // which one you could actually click came down to DOM order (#2750).
   return (
     <button
       onClick={scrollToTop}
-      className="fixed bottom-6 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40"
+      className="fixed bottom-24 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40"
       aria-label="Scroll to top"
     >
       <ArrowUp className="w-5 h-5" />

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-D1VjN2fo.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-GBTQ2eaA.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-TuCPjeGc.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BtvqfqPQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-D1VjN2fo.css">
+    <script type="module" crossorigin src="/assets/index-TuCPjeGc.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов