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

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 2 недель назад
Родитель
Сommit
339682ee58

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

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


Разница между файлами не показана из-за своего большого размера
+ 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>

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