FN-7680: fix Quick Add action row button height parity

Normalizes Quick Add action-row button heights (Save, Attach, Fast, workflow trigger) at desktop and mobile widths so all controls share one box height regardless of icon-only vs text content or .dep-trigger padding.

- Add scoped min-height rule for .quick-entry-actions .btn and .wf-optional-steps-dropdown-trigger in QuickEntryBox.css (desktop base rule, mirrors existing mobile touch-target block)
- Add regression test covering desktop + mobile action-row height parity across button variants
- Add changeset (patch) documenting the fix

Files changed:
 .changeset/FN-7680-quick-add-height-parity.md      |   7 +
 .../quick-entry-action-row-height-parity.test.tsx  | 256 +++++++++++++++++++++
 .../dashboard/app/components/QuickEntryBox.css     |  31 +++
 3 files changed, 294 insertions(+)

Fusion-Task-Id: FN-7680

Fusion-Task-Lineage: 73024883-08f3-41d0-a601-48e223eea57f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 08:59:06 -07:00
parent 297c7444f5
commit 13570e86aa
3 changed files with 294 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Quick Add action-row buttons (Save, Attach, Fast, workflow trigger) rendering at mismatched heights.
category: fix
dev: Adds a scoped `min-height` on `.quick-entry-actions .btn` and `.wf-optional-steps-dropdown-trigger` in `QuickEntryBox.css` (desktop base rule, alongside the existing mobile touch-target block) so every action-row control resolves one uniform box height regardless of icon-only vs text content or `.dep-trigger` padding differences. No shared `.btn`/`.btn-sm`/`.btn-icon`/`.btn-task-create`/`.dep-trigger` rules in `styles.css` were touched.

View File

@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss, loadAllAppCssBaseOnly, loadStylesCss } from "../test/cssFixture";
import { render, screen, act } from "@testing-library/react";
import { QuickEntryBox } from "../components/QuickEntryBox";
import type { Task } from "@fusion/core";
import type { BoardWorkflowDefinition } from "../api";
import { fetchAgents } from "../api";
/*
FNXC:QuickAddActionRow 2026-07-08-00:00:
FN-7680 regression coverage. Save (`btn btn-task-create btn-sm`) resolved a
different box height than its `.quick-entry-actions` siblings (workflow
trigger, Attach, Fast, Priority, Deps, GitHub toggle, Subtask) because of two
independent drift sources: text buttons get an 18px line box from the
inherited body line-height at 12px font-size while icon-only `.btn-icon`
buttons collapse to their bare icon height (`line-height: 0`), and
`.dep-trigger` buttons (Priority, Deps, and — pre-FN-7677 — the workflow
trigger) inherit the shared `.dep-trigger, .inline-create-model-trigger`
rule's shorter 3px/8px padding instead of `.btn-sm`'s 4px/10px. These tests
assert a single scoped `min-height` on `.quick-entry-actions .btn` (plus the
non-`.btn` optional-steps trigger) normalizes every variant to the same total
box height at desktop widths (the existing `@media (max-width: 768px)`
touch-target block already did this for mobile), and that the shared global
`.btn`, `.btn-sm`, `.btn-icon`, `.btn-task-create`, and `.dep-trigger` rules in
styles.css remain untouched for other surfaces (InlineCreateCard,
NewTaskModal, TaskDetailModal, TaskForm).
*/
const mockTasks: Task[] = [
{
id: "FN-001",
title: "Test task 1",
description: "First test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];
const WORKFLOW_A: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const WORKFLOW_B: BoardWorkflowDefinition = {
id: "wf-custom-long-name",
name: "A Rather Long Custom Workflow Name That Should Truncate",
columns: [],
};
vi.mock("../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [],
favoriteProviders: [],
favoriteModels: [],
}),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("lucide-react", () => ({
Link: () => null,
Paperclip: () => null,
Brain: () => null,
Lightbulb: () => null,
ListTree: () => null,
Sparkles: () => null,
Save: () => null,
X: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
Flag: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
vi.mock("../components/ModelSelectionModal", () => ({
ModelSelectionModal: () => null,
}));
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => <div data-testid={`mock-dropdown-${label}`}>{value || "none"}</div>,
}));
function mockDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function renderQuickEntryBox(props: Record<string, unknown> = {}) {
const defaultProps = {
onCreate: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
tasks: mockTasks,
projectId: "test-proj",
workflowId: WORKFLOW_A.id,
defaultWorkflowId: WORKFLOW_A.id,
workflowOptions: [WORKFLOW_A, WORKFLOW_B],
};
return render(<QuickEntryBox {...defaultProps} {...props} />);
}
describe("quick-entry action row height parity (FN-7680)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
localStorage.clear();
});
it("asserts a base (non-media) uniform min-height on .quick-entry-actions .btn covering desktop widths", () => {
const baseOnlyCss = loadAllAppCssBaseOnly();
const match = baseOnlyCss.match(
/\.quick-entry-actions \.btn,\s*\n\s*\.quick-entry-actions \.wf-optional-steps-dropdown-trigger\s*\{[^}]*min-height:\s*([^;]+);/,
);
expect(match).not.toBeNull();
// Not the mobile touch-target literal (calc(var(--space-2xl) + var(--space-xs)));
// this must be a distinct desktop-width value declared outside any @media block.
expect(match![1].trim()).toBe("calc(var(--space-xl) + var(--space-xs))");
});
it("keeps the existing ≤768px touch-target min-height block applying to all .quick-entry-actions .btn (including Save)", () => {
const cssContent = loadAllAppCss();
// Isolate the known FN-1140/FN-6153/FN-6160 mobile touch-target section by
// its marker comment so this assertion cannot accidentally cross into an
// unrelated @media block or the desktop base rule further up the file.
const sectionStart = cssContent.indexOf("Quick Entry Mobile Touch + Overflow Fixes");
expect(sectionStart).toBeGreaterThan(-1);
const section = cssContent.slice(sectionStart, sectionStart + 800);
expect(section).toContain("max-width: 768px");
const mobileBlockMatch = section.match(
/\.quick-entry-actions \.btn,\s*\n\s*\.quick-entry-actions \.wf-optional-steps-dropdown-trigger\s*\{[^}]*min-height:\s*([^;]+);/,
);
expect(mobileBlockMatch).not.toBeNull();
expect(mobileBlockMatch![1].trim()).toBe("calc(var(--space-2xl) + var(--space-xs))");
});
it("does not modify the shared global .btn, .btn-sm, .btn-icon, .btn-task-create, or .dep-trigger rules in styles.css", () => {
const stylesCssContent = loadStylesCss();
const btnMatch = stylesCssContent.match(/\.btn\s*\{[^}]*padding:\s*([^;]+);/);
expect(btnMatch).not.toBeNull();
expect(btnMatch![1].trim()).toBe("var(--btn-padding)");
const btnSmMatch = stylesCssContent.match(/\.btn-sm\s*\{[^}]*padding:\s*([^;]+);/);
expect(btnSmMatch).not.toBeNull();
expect(btnSmMatch![1].trim()).toBe("4px 10px");
const btnIconMatch = stylesCssContent.match(/\.btn-icon\s*\{[^}]*line-height:\s*([^;]+);/);
expect(btnIconMatch).not.toBeNull();
expect(btnIconMatch![1].trim()).toBe("0");
const btnTaskCreateMatch = stylesCssContent.match(/\.btn-task-create\s*\{[^}]*background:\s*([^;]+);/);
expect(btnTaskCreateMatch).not.toBeNull();
expect(btnTaskCreateMatch![1].trim()).toBe("var(--cta-bg)");
const depTriggerMatches = stylesCssContent.match(/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{/g);
expect(depTriggerMatches).not.toBeNull();
expect(depTriggerMatches!.length).toBe(1);
const depTriggerMatch = stylesCssContent.match(
/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(depTriggerMatch).not.toBeNull();
expect(depTriggerMatch![1].trim()).toBe("3px 8px");
});
it("renders Save, workflow trigger, Attach, Fast, Priority, and Deps as sibling .btn elements in the same .quick-entry-actions row", () => {
mockDesktopViewport();
renderQuickEntryBox();
const actionsRow = screen.getByTestId("quick-entry-actions");
expect(actionsRow.classList.contains("quick-entry-actions")).toBe(true);
const save = screen.getByTestId("quick-entry-save");
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const attach = screen.getByTestId("quick-entry-attach");
const fast = screen.getByTestId("quick-entry-fast-toggle");
const priority = screen.getByTestId("quick-entry-priority-button");
const deps = screen.getByTestId("quick-entry-deps");
for (const el of [save, trigger, attach, fast, priority, deps]) {
expect(el.classList.contains("btn")).toBe(true);
expect(actionsRow.contains(el)).toBe(true);
}
});
it("does not render an empty shell for the workflow trigger when fewer than 2 real workflow options exist", () => {
mockDesktopViewport();
renderQuickEntryBox({ workflowOptions: [WORKFLOW_A] });
expect(screen.queryByTestId("quick-entry-workflow-trigger")).toBeNull();
// Every other action-row control still renders and remains a .btn sibling.
const actionsRow = screen.getByTestId("quick-entry-actions");
const save = screen.getByTestId("quick-entry-save");
const attach = screen.getByTestId("quick-entry-attach");
expect(actionsRow.contains(save)).toBe(true);
expect(actionsRow.contains(attach)).toBe(true);
});
});

View File

@@ -184,6 +184,37 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
flex: 1 1 auto;
}
/*
FNXC:QuickAddActionRow 2026-07-08-00:00:
FN-7680 — Save (`btn btn-task-create btn-sm`) rendered a different box height
than its `.quick-entry-actions` siblings. Root cause is content-height drift
across button variants, not one single property: text buttons (Save, Fast,
Subtask, the workflow/priority/deps `.dep-trigger` triggers) get an 18px line
box from the inherited body line-height at 12px font-size, while icon-only
buttons (Attach, GitHub toggle) collapse to their bare ~14px icon because
`.btn-icon` sets `line-height: 0`; separately, `.dep-trigger` buttons (Priority,
Deps, and — pre-FN-7677 — the workflow trigger) inherit the shared
`.dep-trigger, .inline-create-model-trigger` rule's shorter 3px/8px padding
instead of `.btn-sm`'s 4px/10px. Rather than chase each contributor
individually (padding, line-height, icon size) per button variant, assert one
uniform `min-height` scoped to this row: with the project-wide
`box-sizing: border-box` reset, min-height normalizes every `.btn` variant in
`.quick-entry-actions` (plus the non-`.btn` optional-steps trigger) to the same
total box height regardless of its individual content/padding, without editing
the shared `.btn`, `.btn-sm`, `.btn-icon`, `.btn-task-create`, or `.dep-trigger`
rules in styles.css (those still back InlineCreateCard/NewTaskModal/
TaskDetailModal/TaskForm). This is the desktop-width counterpart to the
existing mobile touch-target min-height block further down this file (the
"max-width: 768px" rule) — that block already applies the same
normalization pattern for mobile; this rule closes the gap for desktop widths
so the invariant holds at both breakpoints (FN-5751 lesson: no mobile-only or
desktop-only fix).
*/
.quick-entry-actions .btn,
.quick-entry-actions .wf-optional-steps-dropdown-trigger {
min-height: calc(var(--space-xl) + var(--space-xs));
}
/*
FNXC:QuickAddWorkflow 2026-06-30-00:00:
Quick-add workflow targeting sits in the action row as a compact existing-button dropdown. Use tokenized sizing/colors so the selector wraps with Save/Plan controls on narrow Board and List surfaces without introducing a separate visual hierarchy.