FN-7602: fix Record/Clear button overlap in Keyboard Shortcuts rows

Fixes overlapping Record and Clear buttons on the Keyboard Shortcuts settings rows by replacing the icon-only button class with a text button class and locking layout with flex-shrink.

- Swap ShortcutCaptureInput Record/Clear buttons off the icon-only `btn-icon` class (which forced line-height:0 and a 36px mobile square, clipping labels) onto a text-button class
- Add `.shortcut-capture` row CSS with `flex-shrink:0` on controls so the input and buttons never overlap and stack cleanly on mobile
- Add regression tests covering the Keyboard Shortcuts section layout
- Add changeset documenting the fix

Files changed:
 .changeset/fn-7602-shortcut-row-layout.md          |  7 ++
 .../dashboard/app/components/SettingsModal.css     | 17 ++++
 .../settings/sections/ShortcutCaptureInput.tsx     | 14 +++-
 .../__tests__/KeyboardShortcutsSection.test.tsx    | 95 ++++++++++++++++++++++
 4 files changed, 131 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7602

Fusion-Task-Lineage: 50cf6975-f0fb-42dd-87b0-50578977a0f4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 18:56:24 -07:00
parent 670c41345b
commit b9d60b3c39
4 changed files with 131 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix overlapping Record and Clear buttons in the Keyboard Shortcuts settings rows on desktop and mobile.
category: fix
dev: The shortcut-capture Record/Clear buttons no longer use the icon-only `btn-icon` class (which set `line-height:0` and a mobile 36px square, clipping/overlapping the text labels); they use a text-button class and the `.shortcut-capture` row locks buttons with `flex-shrink:0` so the input and controls never overlap, stacking cleanly on mobile.

View File

@@ -2550,6 +2550,17 @@ FN-7553's dedicated Keyboard Shortcuts section groups every action under a categ
border-bottom: 0;
}
/*
FNXC:DashboardShortcuts 2026-07-05-00:00:
FN-7602 fixes an overlap bug: Record/Clear previously used the icon-only `btn-icon` class
(line-height:0 + mobile 36px square), which clipped/overlapped their text labels
("Record"/"Recording…"/"Clear") against the input and each other (IMG_1305). The input
keeps `flex: 1 1 auto; min-width: 0;` so it shrinks first, while the buttons get
`flex-shrink: 0; white-space: nowrap;` so their content-sized width (including the longer
"Recording…" label) is never crushed or allowed to overlap a neighbor on desktop. Below
768px the row stacks to a column so the buttons sit on their own row under the full-width
input, still non-overlapping.
*/
.shortcut-capture {
display: flex;
align-items: center;
@@ -2566,6 +2577,12 @@ FN-7553's dedicated Keyboard Shortcuts section groups every action under a categ
border-color: var(--color-error);
}
.shortcut-capture__record,
.shortcut-capture__clear {
flex-shrink: 0;
white-space: nowrap;
}
.shortcut-capture__record--active {
color: var(--color-warning);
}

View File

@@ -91,9 +91,19 @@ export function ShortcutCaptureInput({ id, value, defaultValue, invalid, describ
}}
onChange={(event) => onChange(event.target.value)}
/>
{/*
FNXC:DashboardShortcuts 2026-07-05-00:00:
Record/Clear are TEXT-labeled buttons ("Record"/"Recording…"/"Clear"), not icon-only
controls. `btn-icon` sets `line-height: 0` and a mobile 36px square meant for SVG-only
buttons — applying it here clipped the label's line box and, at mobile widths, forced
"Recording…" to overflow the fixed square and overlap the Clear button/input
(reported via screenshot IMG_1305). Use `btn-sm` instead so labels render on a normal
line-height with content-sized width; `.shortcut-capture` locks these buttons with
`flex-shrink: 0` so they never collide with the input or each other.
*/}
<button
type="button"
className={`btn btn-icon shortcut-capture__record${recording ? " shortcut-capture__record--active" : ""}`}
className={`btn btn-sm shortcut-capture__record${recording ? " shortcut-capture__record--active" : ""}`}
aria-pressed={recording}
title={recording ? t("settings.keyboardShortcuts.recordingTitle", "Recording… press Escape to cancel") : t("settings.keyboardShortcuts.recordTitle", "Record a new shortcut")}
onClick={() => (recording ? stopRecording() : startRecording())}
@@ -102,7 +112,7 @@ export function ShortcutCaptureInput({ id, value, defaultValue, invalid, describ
</button>
<button
type="button"
className="btn btn-icon shortcut-capture__clear"
className="btn btn-sm shortcut-capture__clear"
title={t("settings.keyboardShortcuts.clearTitle", "Disable this shortcut")}
onClick={() => {
if (recording) stopRecording();

View File

@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ShortcutCaptureInput } from "../ShortcutCaptureInput";
const settingsModalCss = readFileSync("app/components/SettingsModal.css", "utf8");
/*
FNXC:DashboardShortcuts 2026-07-04-00:00:
FN-7553 covers the press-to-record capture control in isolation before the
@@ -110,4 +113,96 @@ describe("ShortcutCaptureInput", () => {
);
expect(container.querySelector('[data-shortcuts-ignore="true"]')).toBeTruthy();
});
/*
FNXC:DashboardShortcuts 2026-07-05-00:00:
Regression for FN-7602 (IMG_1305): Record/Clear previously carried the
icon-only `btn-icon` class (line-height:0 + mobile 36px square), which
clipped/overlapped the text labels against the input and each other. These
tests assert the buttons use a text-appropriate class across idle,
recording, invalid, and cleared/disabled states, and that the
`.shortcut-capture` CSS locks a non-overlapping desktop row / mobile stack.
*/
describe("row layout (FN-7602 no-overlap regression)", () => {
it("never applies the icon-only btn-icon class to the Record or Clear buttons, idle", () => {
render(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={vi.fn()} />,
);
const recordBtn = screen.getByRole("button", { name: /^record$/i });
const clearBtn = screen.getByRole("button", { name: /clear/i });
for (const btn of [recordBtn, clearBtn]) {
expect(btn.className.split(/\s+/)).not.toContain("btn-icon");
expect(btn.className.split(/\s+/)).toContain("btn");
expect(btn.className).toMatch(/\bbtn-sm\b|\bbtn--sm\b/);
}
});
it("keeps the Record button text-classed and readable while recording (longer label)", () => {
render(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={vi.fn()} />,
);
fireEvent.click(screen.getByRole("button", { name: /^record$/i }));
const recordingBtn = screen.getByRole("button", { name: /recording/i });
expect(recordingBtn.className.split(/\s+/)).not.toContain("btn-icon");
expect(recordingBtn.className).toContain("shortcut-capture__record--active");
expect(recordingBtn).toHaveAttribute("aria-pressed", "true");
// Only a single Record/Recording control exists — no duplicate/overlapping control.
expect(screen.getAllByRole("button", { name: /record/i })).toHaveLength(1);
fireEvent.keyDown(document, { key: "Escape" });
});
it("keeps the invalid-binding style and buttons off btn-icon while invalid", () => {
render(
<ShortcutCaptureInput id="test-shortcut" value="Bogus" defaultValue="Ctrl+E" invalid={true} describedById="test-hint" onChange={vi.fn()} />,
);
expect(screen.getByRole("textbox").className).toContain("shortcut-capture__input--invalid");
for (const btn of [screen.getByRole("button", { name: /^record$/i }), screen.getByRole("button", { name: /clear/i })]) {
expect(btn.className.split(/\s+/)).not.toContain("btn-icon");
}
});
it("still clears a bound value without btn-icon interfering", () => {
const onChange = vi.fn();
render(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /clear/i }));
expect(onChange).toHaveBeenCalledWith("");
});
it("locks the desktop .shortcut-capture row so the input shrinks and buttons never overlap", () => {
const rowRule = settingsModalCss.match(/(?<!__record--active|__input--invalid)\.shortcut-capture\s*\{([^}]*)\}/)?.[1] ?? "";
const inputRule = settingsModalCss.match(/\.shortcut-capture__input\s*\{([^}]*)\}/)?.[1] ?? "";
const buttonRule = settingsModalCss.match(/\.shortcut-capture__record,\s*\n?\s*\.shortcut-capture__clear\s*\{([^}]*)\}/)?.[1] ?? "";
expect(rowRule).toContain("display: flex;");
expect(inputRule).toContain("flex: 1 1 auto;");
expect(inputRule).toContain("min-width: 0;");
expect(buttonRule).toContain("flex-shrink: 0;");
expect(buttonRule).toContain("white-space: nowrap;");
});
it("stacks .shortcut-capture on mobile with a full-width input and no overlapping buttons", () => {
// Anchor to the specific shortcut-capture mobile media block (the one
// immediately following `.shortcut-conflict-banner`) rather than a
// lazily-scoped regex, since the stylesheet has several unrelated
// `@media (max-width: 768px)` blocks earlier in the file that a naive
// lazy `[\s\S]*?` could otherwise bleed across.
const shortcutMediaStart = settingsModalCss.indexOf(".shortcut-conflict-banner");
const shortcutMediaBlock = settingsModalCss.slice(shortcutMediaStart).match(/@media \(max-width: 768px\) \{([\s\S]*?)\n\}/)?.[1] ?? "";
const mobileRowRule = shortcutMediaBlock.match(/\.shortcut-capture\s*\{([^}]*)\}/)?.[1] ?? "";
const mobileInputRule = shortcutMediaBlock.match(/\.shortcut-capture__input\s*\{([^}]*)\}/)?.[1] ?? "";
expect(mobileRowRule).toContain("flex-direction: column;");
expect(mobileInputRule).toContain("width: 100%;");
// The buttons' flex-shrink/nowrap rule is declared once (not overridden away)
// and still applies at mobile widths, so "Recording…" cannot overflow its box.
const buttonRule = settingsModalCss.match(/\.shortcut-capture__record,\s*\n?\s*\.shortcut-capture__clear\s*\{([^}]*)\}/)?.[1] ?? "";
expect(buttonRule).toContain("flex-shrink: 0;");
});
});
});