Explorar el Código

Keep card and row actions reachable without a hover-capable pointer (issue #2865)

Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
On a touch-only device the media query never matches, so the rule that
reveals the control is not merely never triggered: it is never applied.
A control written as opacity-0 group-hover:opacity-100 is invisible for
good. The reporter's iPhone screenshot shows the project card with
nothing where the "..." belongs, and Edit and Delete live only there.

So the hiding half is what has to depend on the pointer, not the
revealing half. A can-hover variant carries the query - the same one the
history thumbnail preview has used since it was written - and the six
controls behind it become can-hover:opacity-0 group-hover:opacity-100.
Without a hover-capable pointer no rule hides them and they simply
render; with one, nothing changes. Every reveal selector is specificity
(0,2,0) against the hider's (0,1,0), so which one wins does not depend on
where they land in the stylesheet.

Six controls were affected: the project card menu, the File Manager's
folder actions (reachable by accident today - the "wrap names" toggle
drops the hover class), duplicate preset, rename and delete tag, delete
print photo, delete plate reference.

Six more already tried to handle this, by viewport width under 768px on
the Archives cards and the File Manager's file cards. That covers a phone
and misses an iPad in landscape, which is touch-only at 1024px. They move
to the capability check and useIsMobile goes with them, along with the
isMobile prop threaded into FileCard.

opacity-0 also leaves a button focusable while invisible, so tabbing
through a card stopped on a control nobody could see. Focus now reveals
them, through group-focus-within on the wrappers and focus-visible on the
standalone buttons - neither is hover-gated.

jsdom does not evaluate media queries, so the tests pin the class
contract instead: a bare opacity-0 is the defect, because it applies
unconditionally while everything that undoes it does not.

The decorative hover reveals are left alone - the archive hash badge, the
project name overlay, the thumbnail preview, the swatch tooltips. Nothing
is unreachable there, only unseen.
maziggy hace 2 semanas
padre
commit
90fac7b529

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests.
 - **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests.
 - **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests.
 - **H2D archives lost their 3MF, thumbnail and filament data after the last update (#2856, reported by @aishlai)** — When a print starts, the printer says where it put the sliced file, and Bambuddy took `brtc://emmc/<name>` — internal storage — as proof there was nothing to fetch, so it archived the print by name alone. On an H2D with a card in the slot that is not true: the same file sits under `/cache` and downloads without complaint, as it had for that reporter's every print until the change landed. Bambuddy now checks instead of assuming. The printer names the exact file, so confirming it takes one connection across five paths rather than the ~110-connection search that made skipping worth doing — and when the file really is out of reach, as it is on an H2C or P2S with no copy on the card, the archive falls back exactly as before and still says why. The connection diagnostic asks the same question before warning that a print is out of reach. Covered by backend tests.
 - **H2D archives lost their 3MF, thumbnail and filament data after the last update (#2856, reported by @aishlai)** — When a print starts, the printer says where it put the sliced file, and Bambuddy took `brtc://emmc/<name>` — internal storage — as proof there was nothing to fetch, so it archived the print by name alone. On an H2D with a card in the slot that is not true: the same file sits under `/cache` and downloads without complaint, as it had for that reporter's every print until the change landed. Bambuddy now checks instead of assuming. The printer names the exact file, so confirming it takes one connection across five paths rather than the ~110-connection search that made skipping worth doing — and when the file really is out of reach, as it is on an H2C or P2S with no copy on the card, the archive falls back exactly as before and still says why. The connection diagnostic asks the same question before warning that a print is out of reach. Covered by backend tests.
 - **A printer with more than one smart plug never recorded energy or energy cost (#2859, reported by @sn8key)** — Linking a second plug to a printer — a dry box, a filter fan, a chamber light you want to switch from the printer card — stopped energy tracking on that printer completely, from the moment the second plug was linked. Per-print energy is the change in one plug's meter between the start and the end of a print, and both readings asked for "the plug on this printer" in a way that accepts one answer and fails on two. The failure was indistinguishable from having no plug at all: the print-end log said no starting reading had been taken, which is also what it says for a printer with nothing linked to it. Bambuddy now picks the printer's own plug — the one marked as supplying its power, and of those the one that actually reports a meter, so accessories drop out on their own — and names the plugs it tried when none of them measures anything. Linking several plugs to a printer is supported and always was; only energy assumed otherwise. Past prints cannot be recovered, since the starting reading was never taken. Covered by backend tests.
 - **A printer with more than one smart plug never recorded energy or energy cost (#2859, reported by @sn8key)** — Linking a second plug to a printer — a dry box, a filter fan, a chamber light you want to switch from the printer card — stopped energy tracking on that printer completely, from the moment the second plug was linked. Per-print energy is the change in one plug's meter between the start and the end of a print, and both readings asked for "the plug on this printer" in a way that accepts one answer and fails on two. The failure was indistinguishable from having no plug at all: the print-end log said no starting reading had been taken, which is also what it says for a printer with nothing linked to it. Bambuddy now picks the printer's own plug — the one marked as supplying its power, and of those the one that actually reports a meter, so accessories drop out on their own — and names the plugs it tried when none of them measures anything. Linking several plugs to a printer is supported and always was; only energy assumed otherwise. Past prints cannot be recovered, since the starting reading was never taken. Covered by backend tests.

+ 136 - 0
frontend/src/__tests__/pages/TouchReachableActions.test.tsx

@@ -0,0 +1,136 @@
+/**
+ * Card and row actions must stay reachable without a hover-capable pointer (#2865).
+ *
+ * Tailwind v4 compiles `group-hover:` inside `@media (hover: hover)`, so on a
+ * touch-only device the reveal rule is never applied and a control written as
+ * `opacity-0 group-hover:opacity-100` is invisible for good — which is how the
+ * project card's action menu and the File Manager's folder actions became
+ * unusable on a phone. The fix moves the HIDING half behind a `can-hover`
+ * variant, so with no such pointer the control simply keeps its own opacity.
+ *
+ * jsdom does not evaluate media queries, so these tests pin the class contract
+ * rather than the computed style: a bare `opacity-0` is the defect, because it
+ * applies unconditionally while everything that undoes it does not.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { ProjectsPage } from '../../pages/ProjectsPage';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { setAuthToken } from '../../api/client';
+
+/** `opacity-0` on its own — with no variant in front of it. */
+const UNCONDITIONALLY_HIDDEN = /(^|\s)opacity-0(\s|$)/;
+
+const mockProjects = [
+  {
+    id: 1,
+    name: 'Functional Parts',
+    description: 'Useful household items',
+    color: '#00ae42',
+    archive_count: 10,
+    total_print_time_seconds: 36000,
+    total_filament_grams: 500,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-15T00:00:00Z',
+  },
+];
+
+const mockFolders = [
+  {
+    id: 1,
+    name: 'Brackets',
+    parent_id: null,
+    file_count: 0,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+];
+
+describe('actions that were hover-only (#2865)', () => {
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  describe('project card', () => {
+    beforeEach(() => {
+      server.use(http.get('/api/v1/projects/', () => HttpResponse.json(mockProjects)));
+    });
+
+    it('does not hide the action menu from a pointer that cannot hover', async () => {
+      render(<ProjectsPage />);
+      await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
+
+      const card = screen.getByText('Functional Parts').closest('div.group')!;
+      const menuButton = within(card).getAllByRole('button').slice(-1)[0];
+
+      expect(menuButton.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
+      expect(menuButton.className).toContain('can-hover:opacity-0');
+    });
+
+    it('still opens Edit and Delete once the menu is tapped', async () => {
+      render(<ProjectsPage />);
+      await waitFor(() => expect(screen.getByText('Functional Parts')).toBeInTheDocument());
+
+      const card = screen.getByText('Functional Parts').closest('div.group')!;
+      const user = userEvent.setup();
+      await user.click(within(card).getAllByRole('button').slice(-1)[0]);
+
+      expect(within(card).getByRole('button', { name: 'Edit' })).toBeInTheDocument();
+      expect(within(card).getByRole('button', { name: 'Delete' })).toBeInTheDocument();
+    });
+  });
+
+  describe('file manager folder row', () => {
+    beforeEach(() => {
+      localStorage.clear();
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'operator1',
+            is_admin: false,
+            permissions: ['library:read_own', 'library:update_all', 'library:delete_all'],
+          }),
+        ),
+        http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
+        http.get('/api/v1/library/files', () => HttpResponse.json([])),
+        http.get('/api/v1/library/stats', () =>
+          HttpResponse.json({
+            total_files: 0,
+            total_folders: 1,
+            total_size_bytes: 0,
+            disk_free_bytes: 10737418240,
+            disk_total_bytes: 107374182400,
+          }),
+        ),
+        http.get('/api/v1/projects/', () => HttpResponse.json([])),
+        http.get('/api/v1/archives/', () => HttpResponse.json([])),
+      );
+    });
+
+    it('does not hide the folder actions from a pointer that cannot hover', async () => {
+      render(<FileManagerPage />);
+      await waitFor(() => expect(screen.getByText('Brackets')).toBeInTheDocument());
+
+      const row = screen.getByText('Brackets').closest('div.group')!;
+      // The kebab menu's wrapper is what carries the visibility classes.
+      const actions = within(row).getAllByRole('button').slice(-1)[0].closest('div.flex-shrink-0')!;
+
+      expect(actions.className).not.toMatch(UNCONDITIONALLY_HIDDEN);
+      expect(actions.className).toContain('can-hover:opacity-0');
+    });
+  });
+});

+ 1 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -466,7 +466,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                   <button
                   <button
                     type="button"
                     type="button"
                     onClick={() => handlePhotoDelete(filename)}
                     onClick={() => handlePhotoDelete(filename)}
-                    className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
+                    className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
                   >
                   >
                     <Trash2 className="w-3 h-3 text-white" />
                     <Trash2 className="w-3 h-3 text-white" />
                   </button>
                   </button>

+ 1 - 1
frontend/src/components/TagManagementModal.tsx

@@ -257,7 +257,7 @@ export function TagManagementModal({ onClose }: TagManagementModalProps) {
                         <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
                         <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
                           {tag.count}
                           {tag.count}
                         </span>
                         </span>
-                        <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                        <div className="flex items-center gap-1 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
                           <button
                           <button
                             onClick={() => startEdit(tag)}
                             onClick={() => startEdit(tag)}
                             className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
                             className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"

+ 10 - 0
frontend/src/index.css

@@ -26,6 +26,16 @@
 /* Enable class-based dark mode for Tailwind v4 */
 /* Enable class-based dark mode for Tailwind v4 */
 @custom-variant dark (&:where(.dark, .dark *));
 @custom-variant dark (&:where(.dark, .dark *));
 
 
+/* Hover-capable pointer only (#2865).  Tailwind v4 compiles `hover:` AND
+   `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the
+   reveal rule is never applied at all — a control written as
+   `opacity-0 group-hover:opacity-100` stays invisible forever, which is how the
+   project card's action menu became unreachable on a phone.  Gating the HIDING
+   half on this variant is what fixes it: with no hover-capable pointer the
+   element simply keeps its natural opacity.  Same query the history thumbnail
+   preview already uses further down. */
+@custom-variant can-hover (@media (hover: hover) and (pointer: fine));
+
 /* Restore the pointer cursor on interactive controls (#2791).  Tailwind v3's
 /* Restore the pointer cursor on interactive controls (#2791).  Tailwind v3's
    Preflight set `button { cursor: pointer }`; v4 dropped it to match the
    Preflight set `button { cursor: pointer }`; v4 dropped it to match the
    browser default of `cursor: default`, so every button in the app looked
    browser default of `cursor: default`, so every button in the app looked

+ 5 - 15
frontend/src/pages/ArchivesPage.tsx

@@ -69,7 +69,6 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { getBedTypeInfo } from '../utils/bedType';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
-import { useIsMobile } from '../hooks/useIsMobile';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
@@ -311,7 +310,6 @@ function ArchiveCard({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission, canModify } = useAuth();
   const { hasPermission, canModify } = useAuth();
-  const isMobile = useIsMobile();
   const navigate = useNavigate();
   const navigate = useNavigate();
   // Name of the printer this archive's saved slicer AMS mapping was resolved
   // Name of the printer this archive's saved slicer AMS mapping was resolved
   // against, or undefined when there is none. Undefined also when the printer
   // against, or undefined when there is none. Undefined also when the printer
@@ -883,9 +881,7 @@ function ArchiveCard({
           <>
           <>
             {/* Left arrow */}
             {/* Left arrow */}
             <button
             <button
-              className={`absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
-                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
-              }`}
+              className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
               onClick={(e) => {
               onClick={(e) => {
                 e.stopPropagation();
                 e.stopPropagation();
                 setCurrentPlateIndex((prev) => {
                 setCurrentPlateIndex((prev) => {
@@ -899,9 +895,7 @@ function ArchiveCard({
             </button>
             </button>
             {/* Right arrow */}
             {/* Right arrow */}
             <button
             <button
-              className={`absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all ${
-                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
-              }`}
+              className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full bg-black/60 hover:bg-black/80 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
               onClick={(e) => {
               onClick={(e) => {
                 e.stopPropagation();
                 e.stopPropagation();
                 setCurrentPlateIndex((prev) => {
                 setCurrentPlateIndex((prev) => {
@@ -915,9 +909,7 @@ function ArchiveCard({
             </button>
             </button>
             {/* Dots indicator */}
             {/* Dots indicator */}
             <div
             <div
-              className={`absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all ${
-                isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
-              }`}
+              className="absolute bottom-1 left-1/2 -translate-x-1/2 flex gap-1 px-2 py-1 rounded-full bg-black/50 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
             >
             >
               {plates.map((plate, idx) => (
               {plates.map((plate, idx) => (
                 <button
                 <button
@@ -935,11 +927,9 @@ function ArchiveCard({
             </div>
             </div>
           </>
           </>
         )}
         )}
-        {/* Context menu button - visible on mobile, shows on hover for desktop */}
+        {/* Context menu button - hover-revealed with a mouse, always there without one (#2865) */}
         <button
         <button
-          className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
-            isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
-          } ${selectionMode ? 'left-10' : ''}`}
+          className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 ${selectionMode ? 'left-10' : ''}`}
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
             const rect = e.currentTarget.getBoundingClientRect();
             const rect = e.currentTarget.getBoundingClientRect();

+ 6 - 12
frontend/src/pages/FileManagerPage.tsx

@@ -70,7 +70,6 @@ import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { LibraryTagsModal } from '../components/LibraryTagsModal';
 import { LibraryTagsModal } from '../components/LibraryTagsModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
-import { useIsMobile } from '../hooks/useIsMobile';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
@@ -662,7 +661,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
             <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
             <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
           </button>
           </button>
         )}
         )}
-        <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
+        <div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
           <div className="relative">
           <div className="relative">
             <button
             <button
               onClick={() => setShowActions(!showActions)}
               onClick={() => setShowActions(!showActions)}
@@ -748,7 +747,6 @@ function isSlicedFilename(filename: string): boolean {
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
-  isMobile: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
@@ -770,7 +768,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -954,8 +952,8 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
         )}
         )}
       </div>
       </div>
 
 
-      {/* Actions - always visible on mobile, hover on desktop */}
-      <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
+      {/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
+      <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
         <button
         <button
           onClick={(e) => {
           onClick={(e) => {
             // No open/close toggle: the menu's own outside-mousedown handler
             // No open/close toggle: the menu's own outside-mousedown handler
@@ -972,11 +970,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
         )}
         )}
       </div>
       </div>
 
 
-      {/* Selection checkbox - always visible on mobile, hover on desktop */}
+      {/* Selection checkbox - hover-revealed with a mouse, always there without one (#2865) */}
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
         isSelected
           ? 'bg-bambu-green border-bambu-green'
           ? 'bg-bambu-green border-bambu-green'
-          : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
+          : 'border-white/30 bg-black/30 can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
       }`}>
       }`}>
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
       </div>
@@ -1109,9 +1107,6 @@ export function FileManagerPage() {
     () => localStorage.getItem('library-show-modified') === 'true'
     () => localStorage.getItem('library-show-modified') === 'true'
   );
   );
 
 
-  // Mobile detection for touch-friendly UI
-  const isMobile = useIsMobile();
-
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
   useEffect(() => {
     const folderParam = searchParams.get('folder');
     const folderParam = searchParams.get('folder');
@@ -2433,7 +2428,6 @@ export function FileManagerPage() {
                     key={file.id}
                     key={file.id}
                     file={file}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
                     isSelected={selectedFiles.includes(file.id)}
-                    isMobile={isMobile}
                     t={t}
                     t={t}
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}

+ 1 - 1
frontend/src/pages/PrintersPage.tsx

@@ -6675,7 +6675,7 @@ function PrinterCard({
                         {/* Delete button */}
                         {/* Delete button */}
                         <button
                         <button
                           onClick={() => handleDeleteRef(ref.index)}
                           onClick={() => handleDeleteRef(ref.index)}
-                          className="absolute top-1 right-1 p-0.5 bg-red-500/80 rounded opacity-0 group-hover:opacity-100 transition-opacity"
+                          className="absolute top-1 right-1 p-0.5 bg-red-500/80 rounded can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
                           title={t('printers.plateDetection.deleteReference')}
                           title={t('printers.plateDetection.deleteReference')}
                         >
                         >
                           <X className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-white" />
                           <X className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-white" />

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

@@ -501,7 +501,7 @@ function PresetListItem({
       </button>
       </button>
       <button
       <button
         onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
         onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
-        className="opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
+        className="can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
         title={t('profiles.presets.duplicate')}
         title={t('profiles.presets.duplicate')}
       >
       >
         <Copy className="w-4 h-4" />
         <Copy className="w-4 h-4" />

+ 1 - 1
frontend/src/pages/ProjectsPage.tsx

@@ -730,7 +730,7 @@ function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermis
           {/* Actions menu */}
           {/* Actions menu */}
           <div className="relative" onClick={(e) => e.stopPropagation()}>
           <div className="relative" onClick={(e) => e.stopPropagation()}>
             <button
             <button
-              className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
+              className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
               onClick={() => setShowActions(!showActions)}
               onClick={() => setShowActions(!showActions)}
             >
             >
               <MoreVertical className="w-4 h-4" />
               <MoreVertical className="w-4 h-4" />

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-LeHzL3OL.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-kSJGQrMr.css


+ 2 - 2
static/index.html

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio