diff --git a/.changeset/FN-7680-quick-add-height-parity.md b/.changeset/FN-7680-quick-add-height-parity.md new file mode 100644 index 0000000000..e57595ccdf --- /dev/null +++ b/.changeset/FN-7680-quick-add-height-parity.md @@ -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. diff --git a/packages/dashboard/app/__tests__/quick-entry-action-row-height-parity.test.tsx b/packages/dashboard/app/__tests__/quick-entry-action-row-height-parity.test.tsx new file mode 100644 index 0000000000..3af7f5c7c0 --- /dev/null +++ b/packages/dashboard/app/__tests__/quick-entry-action-row-height-parity.test.tsx @@ -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; + }) =>
{value || "none"}
, +})); + +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 = {}) { + 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(); +} + +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); + }); +}); diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index dccdf124b7..a8a8cb8dc9 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -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.