diff --git a/frontend/src/__tests__/components/FilamentHoverCard.test.tsx b/frontend/src/__tests__/components/FilamentHoverCard.test.tsx index 091c2803a..484f77673 100644 --- a/frontend/src/__tests__/components/FilamentHoverCard.test.tsx +++ b/frontend/src/__tests__/components/FilamentHoverCard.test.tsx @@ -152,6 +152,28 @@ describe('FilamentHoverCard', () => { // removed that gate so users who don't want to scan via SpoolBuddy NFC // can still pick a BL spool from inventory the same way they pick a // third-party one. + // Paired with the EmptySlotHoverCard assertion below (#2791) — together + // they pin the two render paths to the same Assign-then-Configure order. + it('lists Assign Spool above Configure (#2791)', async () => { + renderWithHover( + +
trigger
+
+ ); + vi.advanceTimersByTime(100); + await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument()); + + const assign = screen.getByText(/assign spool/i); + const configure = screen.getByText(/^configure$/i); + expect(assign.compareDocumentPosition(configure)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + describe('inventory section vendor visibility (#1133)', () => { it('shows the assign-spool button on a Bambu Lab slot when the spool is unassigned', async () => { const onAssign = vi.fn(); @@ -485,6 +507,31 @@ describe('EmptySlotHoverCard (#1133)', () => { expect(onAssign).toHaveBeenCalledTimes(1); }); + // #2791: the empty-slot and filled-slot cards are separate render paths + // that had drifted into opposite orders, so the menu reshuffled itself + // depending on whether the slot happened to hold filament. Both now put + // the spool action above the slot action; assert it on both paths so the + // two can't drift apart again. + it('lists Assign Spool above Configure, matching the filled-slot card (#2791)', async () => { + const result = render( + +
trigger
+
+ ); + fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement); + vi.advanceTimersByTime(100); + await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument()); + + const assign = screen.getByText(/assign spool/i); + const configure = screen.getByText(/^configure$/i); + expect(assign.compareDocumentPosition(configure)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631). describe('dismissal when an action opens a dialog (#2631)', () => { it('closes the card when Configure is pressed, and still configures', async () => { diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index d2a50ca85..4c74b3811 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -25,7 +25,7 @@ interface CardSectionProps { export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) { return (
@@ -397,7 +397,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp dismiss(); navigate(`/inventory?spool=${inventory.assignedSpool!.id}`); }} - className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green" + className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/40 text-bambu-green" title={t('inventory.openInInventory')} > @@ -411,7 +411,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp dismiss(); inventory.onUnassignSpool?.(); }} - className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400" + className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400" > {t('inventory.unassignSpool')} @@ -426,8 +426,8 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp inventory.onAssignSpool?.(); }} disabled={!!inventory.isAssigned} - className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 text-bambu-blue ${ - inventory.isAssigned ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bambu-blue/30' + className={`w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 text-bambu-blue ${ + inventory.isAssigned ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bambu-blue/40' }`} > @@ -446,7 +446,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp dismiss(); configureSlot.onConfigure?.(); }} - className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue" + className="w-full flex items-center justify-start gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/40 text-bambu-blue" title={t('ams.configureSlot')} > @@ -506,7 +506,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp spoolman?.onUnlinkSpool?.(); setShowUnlinkConfirm(false); }} - className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400" + className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400" > {t('inventory.unassignSpool')} @@ -622,6 +622,20 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on {/* Configure slot button */} {(configureSlot?.enabled || onAssignSpool || actions) && (
+ {/* Assign before Configure, matching the filled-slot card + above (#2791). The two cards are separate render paths + and had drifted into opposite orders, so the menu + reshuffled itself depending on whether the slot happened + to hold filament. */} + {onAssignSpool && ( + + )} {configureSlot?.enabled && ( )} - {onAssignSpool && ( - - )} {actions && (
{actions} diff --git a/frontend/src/index.css b/frontend/src/index.css index d97909130..07561a219 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,6 +26,28 @@ /* Enable class-based dark mode for Tailwind v4 */ @custom-variant dark (&:where(.dark, .dark *)); +/* Restore the pointer cursor on interactive controls (#2791). Tailwind v3's + Preflight set `button { cursor: pointer }`; v4 dropped it to match the + browser default of `cursor: default`, so every button in the app looked + unclickable unless someone remembered to add `cursor-pointer` by hand. + Only a handful of the ~930 buttons did, which is why the UI felt + inconsistent rather than uniformly wrong. + + This lives in `base`, the lowest of Tailwind's cascade layers, so the + `cursor-not-allowed` / `disabled:cursor-*` utilities dotted around the + codebase still win. The `:not(:disabled)` guard covers the elements that + are disabled without also carrying such a utility. */ +@layer base { + button:not(:disabled), + select:not(:disabled), + summary, + input[type="checkbox"]:not(:disabled), + input[type="radio"]:not(:disabled), + [role="button"]:not([aria-disabled="true"]) { + cursor: pointer; + } +} + @theme { /* Accent colors - use CSS variables for theming */ --color-bambu-green: var(--accent); diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index a6b5b5629..3f72570f8 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -740,7 +740,8 @@ function ArchiveCard({ { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'), - icon: , + // Preview the favourited state on hover so the row reads as clickable (#2791). + icon: , onClick: () => favoriteMutation.mutate(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, @@ -2138,7 +2139,8 @@ function ArchiveListRow({ { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'), - icon: , + // Preview the favourited state on hover so the row reads as clickable (#2791). + icon: , onClick: () => favoriteMutation.mutate(), disabled: !canModify('archives', 'update', archive.created_by_id), title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined, diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 80bd802e2..27ee8e520 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -466,7 +466,7 @@ function SortableQueueItem({ ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''} ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''} ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''} - ${isMobileSelectable ? 'sm:cursor-default' : ''} + ${isMobileSelectable ? 'cursor-pointer sm:cursor-default' : ''} `} onClick={isMobileSelectable ? () => { if (window.innerWidth < 640) onToggleSelect();