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 (
+ {/* 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();