diff --git a/.claude/rules/fleet-frontend.md b/.claude/rules/fleet-frontend.md
index 2bca64aebcb..e1f6776536c 100644
--- a/.claude/rules/fleet-frontend.md
+++ b/.claude/rules/fleet-frontend.md
@@ -103,6 +103,7 @@ Render software title names via `getDisplayedSoftwareName(name, display_name)` f
- Use `classnames()` for conditional classes
- Style files use underscore prefix: `_styles.scss`
- Prefer `gap` over `margin` for spacing between sibling elements when the parent is `display: flex`/`grid`. Use the layout mixins from `frontend/styles/var/mixins.scss`: `vertical-card-layout`, `vertical-form-layout`, `vertical-modal-layout`, `vertical-page-layout`, `vertical-page-tab-panel-layout`, `vertical-data-set-layout`
+- **Row hover-reveal actions** (a button/dropdown that only appears when the containing row is hovered): add `className="row-hover-button"` to the element. The fade + `:focus-visible` reveal is defined for `tr` in `frontend/components/TableContainer/_styles.scss` and for `.paginated-list__row` in `frontend/components/PaginatedList/_styles.scss` — don't hand-roll a local `opacity: 0` / `:hover { opacity: 1 }` per-consumer. Never fade only `.children-wrapper` — bordered/filled `Button` variants (e.g. `secondary`) leave an empty button frame behind.
## Forms
Cap free-text inputs' `maxLength` to the backend column length (check `server/datastore/mysql/schema.sql`, don't guess) via `inputOptions={{ maxLength: NAME_MAX_LENGTH }}` on `InputField`, using a local constant.
diff --git a/frontend/components/PaginatedList/_styles.scss b/frontend/components/PaginatedList/_styles.scss
index 4fe5eca467f..10658c29e3f 100644
--- a/frontend/components/PaginatedList/_styles.scss
+++ b/frontend/components/PaginatedList/_styles.scss
@@ -71,6 +71,25 @@
min-width: 0;
/* This is crucial for proper shrinking */
}
+
+ // Fade the entire button (border + fill + children), not just its children,
+ // so bordered/filled variants like `secondary` don't leave an empty box behind.
+ .row-hover-button {
+ opacity: 0;
+ // Suppress pointer events while hidden so the invisible button can't
+ // intercept clicks meant for the row. Tab navigation still works —
+ // pointer-events only gates mouse/touch, not keyboard focus.
+ pointer-events: none;
+ transition: opacity 250ms;
+
+ // Reveal on the button's own keyboard focus, not just row hover —
+ // otherwise tabbing to a hidden row-hover button never shows it.
+ &:focus-visible {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+
&:not(.paginated-list__row--disabled):hover {
background: $ui-off-white;
cursor: pointer;
@@ -82,6 +101,11 @@
.policy-row__preview-button {
visibility: visible;
}
+
+ .row-hover-button {
+ opacity: 1;
+ pointer-events: auto;
+ }
}
&:first-child {
diff --git a/frontend/components/TableContainer/_styles.scss b/frontend/components/TableContainer/_styles.scss
index 5bf60ede64b..2b3473189f5 100644
--- a/frontend/components/TableContainer/_styles.scss
+++ b/frontend/components/TableContainer/_styles.scss
@@ -258,6 +258,10 @@
tr {
.row-hover-button {
opacity: 0;
+ // Suppress pointer events while hidden so the invisible button can't
+ // intercept clicks meant for the row. Tab navigation still works —
+ // pointer-events only gates mouse/touch, not keyboard focus.
+ pointer-events: none;
transition: 250ms;
text-overflow: none;
@@ -265,12 +269,16 @@
// otherwise tabbing directly to a hidden row-hover button never shows it.
&:focus-visible {
opacity: 1;
+ pointer-events: auto;
}
// React-select's dropdown opacity must be controlled at input level for keyboard nav
// So must be controlled at input level here as well
&.actions-dropdown {
opacity: 1;
+ // React-select handles its own keyboard nav; keep it hit-testable
+ // so focus/blur bookkeeping continues to work even before row hover.
+ pointer-events: auto;
.actions-dropdown-select__control {
opacity: 0;
@@ -285,6 +293,7 @@
&:focus-visible {
.row-hover-button {
opacity: 1;
+ pointer-events: auto;
}
.row-hover-button.actions-dropdown {
.actions-dropdown-select__control {
diff --git a/frontend/components/buttons/Button/_styles.scss b/frontend/components/buttons/Button/_styles.scss
index 2f3b06292f1..00506c1f266 100644
--- a/frontend/components/buttons/Button/_styles.scss
+++ b/frontend/components/buttons/Button/_styles.scss
@@ -127,6 +127,11 @@ $base-class: "button";
opacity: 0;
}
+ // Wraps children so the loading spinner can swap in without a layout shift.
+ // Do NOT hide-then-reveal buttons by fading `.children-wrapper` alone —
+ // bordered/filled variants (`secondary` etc.) will leave an empty button
+ // box behind. Fade the whole button, or use `.row-hover-button`
+ // (see PaginatedList / TableContainer styles).
.children-wrapper {
display: flex;
flex-direction: row;
diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx
index e52ab7bd785..b5948f53a48 100644
--- a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchPaginatedList/RunScriptBatchPaginatedList.tsx
@@ -79,6 +79,7 @@ const RunScriptBatchPaginatedList = ({
<>
{script.name}