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

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 3 недель назад
Родитель
Сommit
19e744f322

+ 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>

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