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;
|
||||
}
|
||||
|
||||
/*
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user