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:
7
.changeset/FN-7680-quick-add-height-parity.md
Normal file
7
.changeset/FN-7680-quick-add-height-parity.md
Normal 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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -184,6 +184,37 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
|
|||||||
flex: 1 1 auto;
|
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:
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user