Jelajahi Sumber

fix(ui): keep the progress toast on-screen in the installed iPhone PWA (#2612)

The dispatch progress toast is a fixed 420px wide and the toast viewport is
anchored 80px from the right (to clear the bug-report bubble). On a 390px-wide
phone that overflows the left edge by ~110px, so in the Home-Screen PWA the
toast was clipped off the left, with text bleeding past the edge.

Cap every toast to a viewport-relative max-width (calc(100vw - 6rem - safe-area
insets)) so it can't exceed the screen; desktop keeps the 420px. Make the
viewport position safe-area-aware (env(safe-area-inset-*) on bottom/right) so an
installed PWA clears the home indicator and a landscape notch, and add
min-w-0/shrink-0 to the per-job filename row so long names truncate instead of
widening the toast at the narrower phone width.

Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the
width cap; the suppression test's viewport lookup moved to a stable data-testid.
maziggy 1 bulan lalu
induk
melakukan
4ef6772c51

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
 
 ### Fixed
+- **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap.
 - **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile.
 - **`queue_max_concurrent_uploads` behaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl)** — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. **Root cause.** `check_queue()` awaited `_dispatch_selected()`, which awaited `asyncio.gather()` over the whole selected batch before returning — so the scheduler's run loop was blocked until the *slowest* FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason: `_start_print` flips a row `pending → printing` only *after* its upload completes, so returning early would have let the next pass re-dispatch the still-`pending` in-flight rows. **Fix.** Uploads now run as independent background tasks tracked in a `_inflight` pool. Each tick excludes in-flight item rows (and their printers) from selection, launches at most `limit − len(_inflight)` new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; the `pending → printing` CAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads).
 - **Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl)** — Selecting a built-in preset (e.g. Generic ABS) through **Printer → AMS slot → Configure** briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). **Root cause.** The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a `GF*` `tray_info_idx` but an **empty** `setting_id` (those presets carry no Bambu Cloud setting id of their own), and the `configure_ams_slot` route forwarded that empty value straight to `ams_filament_setting`. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. **Fix.** `configure_ams_slot` now back-fills `setting_id` from the resolved `tray_info_idx` via `filament_id_to_setting_id` whenever the client sent none (e.g. `GFB99` → `GFSB99`), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend. `P*` user presets and already-`GFS*` values are left untouched, and an explicitly-supplied `setting_id` (including the `PFUS*` pair) still passes through unchanged. Covered by tests for the built-in empty-`setting_id` case and the material-only generic-fallback case both publishing a derived `GFS*` id.

+ 4 - 3
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -104,14 +104,15 @@ describe('SpoolBuddyLayout', () => {
 
   it('suppresses the global toast viewport while mounted', () => {
     const { unmount } = renderLayout();
-    // Visible viewport gets `hidden` class while the kiosk is up.
-    const viewport = document.querySelector('div.fixed.bottom-4.right-20');
+    // Visible viewport gets `hidden` class while the kiosk is up. Position is
+    // set via safe-area calc() (#2612) so match the stable data-testid.
+    const viewport = document.querySelector('[data-testid="toast-viewport"]');
     expect(viewport?.className).toContain('hidden');
 
     // Cleanup restores the viewport when the kiosk unmounts (e.g. user
     // navigates back to the main app).
     unmount();
-    const viewportAfter = document.querySelector('div.fixed.bottom-4.right-20');
+    const viewportAfter = document.querySelector('[data-testid="toast-viewport"]');
     // After unmount the toast container is gone with the provider; the
     // important guarantee is the suppression flag was untoggled, which the
     // ToastContext tests pin directly. Here we only assert no crash on

+ 24 - 2
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -118,8 +118,9 @@ describe('ToastContext viewport suppression', () => {
       </ToastProvider>
     );
 
-    // Toast viewport is the fixed-position container with bottom-4 right-20.
-    const findViewport = () => container.querySelector('div.fixed.bottom-4.right-20');
+    // Toast viewport is the fixed-position container; position is set via
+    // safe-area calc() (#2612) so match the stable data-testid, not classes.
+    const findViewport = () => container.querySelector('[data-testid="toast-viewport"]');
     expect(findViewport()?.className).not.toContain('hidden');
 
     act(() => {
@@ -140,4 +141,25 @@ describe('ToastContext viewport suppression', () => {
     });
     expect(findViewport()?.className).not.toContain('hidden');
   });
+
+  it('caps every toast to the viewport width so it cannot run off-screen (#2612)', () => {
+    const { container, getByTestId } = render(
+      <ToastProvider>
+        <ViewportProbe />
+      </ToastProvider>
+    );
+
+    act(() => {
+      getByTestId('show-toast').click();
+    });
+
+    // The fixed-width dispatch toast (420px) overflowed the left edge of a
+    // phone in an installed PWA. Every toast now carries a viewport-relative
+    // max-width so it stays on-screen; pin it here.
+    const viewport = container.querySelector('[data-testid="toast-viewport"]');
+    const toast = viewport?.querySelector<HTMLElement>('div[style]');
+    expect(toast?.style.maxWidth).toContain('100vw');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-left');
+    expect(toast?.style.maxWidth).toContain('safe-area-inset-right');
+  });
 });

+ 25 - 4
frontend/src/contexts/ToastContext.tsx

@@ -316,14 +316,32 @@ export function ToastProvider({ children }: { children: ReactNode }) {
 
       {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
           The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
-          free of main-app notifications. */}
-      <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
+          free of main-app notifications.
+          Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an
+          installed PWA on a notched phone clears the home indicator / landscape notch
+          (#2612): the 5rem right offset keeps clearance for the bug bubble. */}
+      <div
+        data-testid="toast-viewport"
+        className={`fixed z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}
+        style={{
+          bottom: 'calc(1rem + env(safe-area-inset-bottom))',
+          right: 'calc(5rem + env(safe-area-inset-right))',
+        }}
+      >
         {toasts.map((toast) => (
           <div
             key={toast.id}
             className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
               toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
             }`}
+            // Cap width to the viewport so the fixed-width dispatch toast (420px)
+            // can't run off the left edge on a phone (#2612). At the cap the toast
+            // sits 1rem + safe-area from the left; on desktop the 420px wins. The
+            // 6rem = the 5rem right offset above + a 1rem left gutter.
+            style={{
+              maxWidth:
+                'calc(100vw - 6rem - env(safe-area-inset-left) - env(safe-area-inset-right))',
+            }}
             data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
           >
             {toast.dispatchData ? (
@@ -389,11 +407,14 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                           data-testid={`dispatch-toast-job-${job.jobId}`}
                         >
                           <div className="flex items-center justify-between gap-2">
-                            <span className="text-xs text-white truncate" title={job.sourceName}>
+                            {/* min-w-0 + flex-1 lets truncate actually kick in
+                                when the toast is capped to a phone's width
+                                (#2612); the status chip stays put with shrink-0. */}
+                            <span className="text-xs text-white truncate min-w-0 flex-1" title={job.sourceName}>
                               {job.sourceName}
                             </span>
                             <span
-                              className="text-[11px] uppercase tracking-wide text-bambu-gray"
+                              className="text-[11px] uppercase tracking-wide text-bambu-gray shrink-0"
                               data-testid={`dispatch-toast-status-${job.jobId}`}
                             >
                               {t(`dispatchToast.status.${job.status}`)}

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CREN25a-.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-G-CyGmAG.js"></script>
+    <script type="module" crossorigin src="/assets/index-CREN25a-.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
   </head>
   <body>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini