Restore the pointer cursor on interactive controls (#2791)

Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
This commit is contained in:
maziggy
2026-08-08 10:06:45 +02:00
parent 14d0d14365
commit 82d6d98266
12 changed files with 110 additions and 33 deletions
+1
View File
@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **Buttons show a pointer cursor again, and the AMS slot menu stops reshuffling itself (#2791, reported by @AnthonyGrondin)** — Hovering most of Bambuddy gave you an arrow, not the little hand that says "this does something". Not everywhere, though, which is what made it read as sloppiness rather than a bug: the update pill was inert while the buttons beside it were fine, a bed or nozzle tile responded but the history-graph button tucked into its corner did not, and dropdowns went either way with no pattern behind it. The pattern was there. Tailwind v3 gave every button a pointer cursor as part of its baseline styling; Tailwind v4, which Bambuddy has used since the interface was built, deliberately dropped that rule to match what browsers do on their own — and browsers give a button the ordinary arrow. From then on a button only looked clickable if whoever wrote it had said so by hand. Fifteen of about nine hundred and thirty had. None of the hundred and forty-nine dropdowns had, and of the checkboxes and radio buttons, nineteen out of a hundred and thirty. The rule is now restored once, centrally, rather than pinned onto individual buttons for the rest of the project's life: buttons, dropdowns, checkboxes, radio buttons, disclosure arrows and anything explicitly marked up as a button all point again. It sits at the bottom of the styling order, so the places that deliberately show a "not allowed" cursor on a disabled control still win, and a control that is genuinely disabled is left alone. Modal backgrounds are deliberately untouched: clicking one closes the dialog, but a full-screen sheet that claims to be a button is worse than one that says nothing. Separately, and behind the same report: the menu on an AMS slot listed **Configure** above **Assign Spool** on an empty slot and the other way round on a filled one, because the two are drawn by different code that had quietly drifted apart — both now lead with the spool action, and a test pins each side so they cannot drift again. The buttons in that menu centred their own text, which left their icons in a ragged column; they are aligned to the left edge now. Their hover shading was a ten-percent step that was very hard to see, and is now twice that. And the star on **Add to favourites** turns yellow as you hover it, so it previews what clicking will do.
- **A job queued to "Any {model}" now switches a printer on, like a job queued to one printer always has (#2786, reported by @TheUltimateC0der)** — Queue a print against a printer class -- **Any X1C**, or a Slicer Pipeline whose target type is **Printer class** -- with every printer of that class switched off at the wall, and nothing happened. The job sat pending, no smart plug was touched, and the only way out was to edit the item onto a specific printer, at which point Bambuddy powered that printer on immediately. The reporter's log holds that comparison exactly: thirteen minutes of the job being polled and passed over, then the edit, then a power-on on the very next check -- same job, same plug, same Auto Power On setting. Powering a printer on had only ever been written into the branch that handles a job pinned to one printer; the branch that picks a printer by model listed an offline one as a reason to keep waiting and never looked at its plugs. It does now. It also picks with a little more care than the older branch: a printer waiting for a plate-clear acknowledgment is passed over, because switching it on only leaves it idling behind that gate -- which is what the reporter's own log shows happening for the eighty minutes after their manual edit -- and a printer whose class the file cannot legally run on is never switched on at all. One printer comes up per queue check rather than a whole shelf at once, so several queued jobs wake several printers over the following minutes. Finally, a printer that is off and has no enabled Auto Power On plug now says that in the job's waiting reason instead of hiding behind the same "Offline" as the printers Bambuddy can bring back itself -- that distinction was the first question the reporter had to be asked.
- **A printer whose file service stops answering is named as such instead of quietly emptying your archives (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Two printers went on printing normally while every archive they produced arrived holding nothing but a filename: no filament totals, no layer count, no cover image, no timelapse. The Connection Diagnostic reported the file-transfer port as reachable, because it was — the printer accepted the connection and then answered it with something that was not TLS at all, and the sliced file could never be read back. Bambuddy said nothing about that on screen; it retried. Because each candidate location opened its own connection, one reporter's log carried 1813 identical handshake failures and another's 3511, against a printer that could not have answered any of them. This is not a model or a firmware problem — the same printers worked for days before and after the fault, and other installs run the same models untouched. It is the printer's own file service getting stuck, and a power-cycle clears it. Bambuddy now stops after the first failed handshake and leaves that printer alone for five minutes, so the log carries a handful of entries that say what went wrong and what to do about it rather than thousands that say neither. The Connection Diagnostic now completes a real handshake instead of merely opening the port, so a printer in this state reads as a warning that names a restart — not as a green tick. Scanning for a timelapse on such a printer reports the file service, where it used to return one error message that covered both "the printer is unreachable" and "this printer has no timelapse folder", and asking for a cover image says the same thing instead of the "no cover for this print" it used to claim. Printing is unaffected throughout: the control connection is a separate service, which is exactly why the fault was invisible.
- **Prints queued from a Slicer Pipeline or the Library are checked for enough filament again (#2779, reported by @wylyn3d)** — A job needing 20.5 g was dispatched onto a spool holding 9 g, and the printer started. The same file, printed from the Print dialog, was correctly refused. The check that stands between the queue and the printer reads the sliced file to learn how much each slot needs, and it looked for that file in the wrong place: a file in the Library records where it lives relative to Bambuddy's data directory, and this one check read that as a path from wherever the process happened to be running. It found nothing, and a source it cannot find has always meant "nothing to verify" rather than "stop" — so the job passed a check that never actually ran. Every path that queues a Library file was affected: Slicer Pipeline jobs, which are always Library-backed, and anything added through the Library's **Add to queue**. Both the automatic dispatcher and the Play button on the queue were equally blind, so the deficit could not be caught by starting the job by hand either. Prints queued from print history were never affected, and neither was the Print dialog, which finds the file its own way. Two things changed: the check now resolves a Library file the same way the eleven other places that read one already did, and a source file that is configured but missing now writes a warning to the log naming the item and the path it looked at. That case still dispatches — the upload needs the same file moments later and fails there, where blocking would strand a queue on a file the user may have moved — but it no longer passes in silence, which is what let this go unnoticed. Covered by backend tests, including the reporter's exact 20.5 g against 9 g.
@@ -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(
<FilamentHoverCard
data={baseFilamentData}
inventory={{ assignedSpool: null, onAssignSpool: vi.fn() }}
configureSlot={{ enabled: true, onConfigure: vi.fn() }}
>
<div>trigger</div>
</FilamentHoverCard>
);
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(
<EmptySlotHoverCard
configureSlot={{ enabled: true, onConfigure: vi.fn() }}
onAssignSpool={vi.fn()}
>
<div>trigger</div>
</EmptySlotHoverCard>
);
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 () => {
+1 -1
View File
@@ -25,7 +25,7 @@ interface CardSectionProps {
export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
return (
<div
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${onClick ? 'cursor-pointer' : ''} ${className}`}
onClick={onClick}
onContextMenu={onContextMenu}
{...rest}
+1 -1
View File
@@ -265,7 +265,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
}}
disabled={item.disabled}
title={item.title}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
className={`group w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
item.disabled
? 'text-bambu-gray cursor-not-allowed'
: item.danger
+22 -17
View File
@@ -344,7 +344,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
dismiss();
navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
}}
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')}
>
<Package className="w-3.5 h-3.5" />
@@ -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')}
>
<Package className="w-3.5 h-3.5" />
@@ -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"
>
<Unlink className="w-3.5 h-3.5" />
{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'
}`}
>
<Package className="w-3.5 h-3.5" />
@@ -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')}
>
<Settings2 className="w-3.5 h-3.5" />
@@ -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')}
</button>
@@ -622,6 +622,20 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
{/* Configure slot button */}
{(configureSlot?.enabled || onAssignSpool || actions) && (
<div className="px-2 pb-2 space-y-1">
{/* 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 && (
<button
onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
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"
>
<Package className="w-3.5 h-3.5" />
{t('inventory.assignSpool')}
</button>
)}
{configureSlot?.enabled && (
<button
onClick={(e) => {
@@ -629,22 +643,13 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
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')}
>
<Settings2 className="w-3.5 h-3.5" />
{t('ams.configure')}
</button>
)}
{onAssignSpool && (
<button
onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
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"
>
<Package className="w-3.5 h-3.5" />
{t('inventory.assignSpool')}
</button>
)}
{actions && (
<div className="pt-1 mt-1 border-t border-bambu-dark-tertiary space-y-1">
{actions}
+22
View File
@@ -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);
+4 -2
View File
@@ -740,7 +740,8 @@ function ArchiveCard({
{ label: '', divider: true, onClick: () => {} },
{
label: archive.is_favorite ? t('archives.menu.removeFromFavorites') : t('archives.menu.addToFavorites'),
icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
// Preview the favourited state on hover so the row reads as clickable (#2791).
icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : canModify('archives', 'update', archive.created_by_id) ? 'group-hover:text-yellow-400' : ''}`} />,
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: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
// Preview the favourited state on hover so the row reads as clickable (#2791).
icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : canModify('archives', 'update', archive.created_by_id) ? 'group-hover:text-yellow-400' : ''}`} />,
onClick: () => favoriteMutation.mutate(),
disabled: !canModify('archives', 'update', archive.created_by_id),
title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
+1 -1
View File
@@ -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();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-B4S191kl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJ8Q_OV9.css">
<script type="module" crossorigin src="/assets/index-B5-gTMPk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jR9g-cOh.css">
</head>
<body>
<div id="root"></div>