diff --git a/.changeset/fn-9024-quick-add-center-action-row.md b/.changeset/fn-9024-quick-add-center-action-row.md new file mode 100644 index 0000000000..fe3bf523da --- /dev/null +++ b/.changeset/fn-9024-quick-add-center-action-row.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Center the Quick Add composer's bottom row of action buttons. +category: fix +dev: CSS-only QuickEntryBox change; preserves the ≤768px space-between layout. diff --git a/packages/dashboard/app/__tests__/quick-entry-action-row-centering.css.test.ts b/packages/dashboard/app/__tests__/quick-entry-action-row-centering.css.test.ts new file mode 100644 index 0000000000..375a88d818 --- /dev/null +++ b/packages/dashboard/app/__tests__/quick-entry-action-row-centering.css.test.ts @@ -0,0 +1,138 @@ +/* @vitest-environment jsdom */ +/* +FNXC:QuickAddActionRow 2026-08-13-05:24: +## Symptom Verification +At desktop/tablet widths the Quick Add row previously split its options left and primary actions +right. This declaration guard requires a centered base row while retaining the <=768px edge-to-edge +mobile override. + +## Surface Enumeration +QuickEntryBox shares this stylesheet across Board, List, modal, Chat, and floating hosts. This test +covers sparse rendered groups, Save-last DOM order, and no spacer shell. jsdom cannot lay out flexbox, +so CSS assertions are a source-contract guard rather than visual layout proof. +*/ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { createElement } from "react"; +import { QuickEntryBox } from "../components/QuickEntryBox"; + +vi.mock("../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), + 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, + Play: () => null, + X: () => null, + ChevronDown: () => null, + ChevronUp: () => null, + ChevronRight: () => null, + Bot: () => null, + Server: () => null, + ArrowDown: () => null, + ArrowUp: () => null, + Flag: () => null, + TriangleAlert: () => null, + Zap: () => null, + Eye: () => null, + EyeOff: () => null, + Github: () => null, + Maximize2: () => null, + Minimize2: () => null, +})); +vi.mock("../components/ModelSelectionModal", () => ({ ModelSelectionModal: () => null })); +vi.mock("../components/CustomModelDropdown", () => ({ CustomModelDropdown: () => null })); +vi.mock("../hooks/useComposerDictation", () => ({ + useComposerDictation: () => ({ micProps: { enabled: false, supported: false, state: "idle", start: vi.fn(), stop: vi.fn() } }), +})); + +const css = readFileSync(resolve(__dirname, "../components/QuickEntryBox.css"), "utf8"); + +function ruleBody(selector: string): { body: string; index: number } { + const index = css.indexOf(`${selector} {`); + expect(index, `rule "${selector}" must exist`).toBeGreaterThan(-1); + const start = css.indexOf("{", index) + 1; + let depth = 1; + let cursor = start; + while (cursor < css.length && depth > 0) { + if (css[cursor] === "{") depth += 1; + if (css[cursor] === "}") depth -= 1; + cursor += 1; + } + return { body: css.slice(start, cursor - 1), index }; +} + +function isInsideMediaQuery(index: number): boolean { + const before = css.slice(0, index); + const stack: boolean[] = []; + for (const match of before.matchAll(/@media[^{]*\{|\{|\}/g)) { + if (match[0].startsWith("@media")) stack.push(true); + else if (match[0] === "{") stack.push(false); + else stack.pop(); + } + return stack.some(Boolean); +} + +function mobileSection(): string { + const marker = css.indexOf("Quick Entry Mobile Touch + Overflow Fixes"); + const start = css.indexOf("@media (max-width: 768px)", marker); + const end = css.indexOf("\n@media", start + 1); + expect(marker).toBeGreaterThan(-1); + expect(start).toBeGreaterThan(marker); + expect(end).toBeGreaterThan(start); + return css.slice(start, end); +} + +describe("QuickEntryBox action-row centering", () => { + it("centers the desktop/tablet cluster without restoring its pre-fix split sizing", () => { + const actions = ruleBody(".quick-entry-actions"); + expect(actions.body).toMatch(/justify-content:\s*center/); + expect(isInsideMediaQuery(actions.index)).toBe(false); + + const primary = ruleBody(".quick-entry-primary-group"); + expect(primary.body).not.toMatch(/margin-left:\s*auto/); + expect(primary.body).toMatch(/flex-wrap:\s*wrap/); + expect(primary.body).toMatch(/justify-content:\s*flex-end/); + + const options = ruleBody(".quick-entry-options-group"); + expect(options.body).not.toMatch(/flex:\s*1\s+1\s+auto/); + expect(options.body).not.toMatch(/(?:#[0-9a-f]{3,8}|rgba?\(|\d+(?:\.\d+)?px)/i); + expect(primary.body).not.toMatch(/(?:#[0-9a-f]{3,8}|rgba?\(|\d+(?:\.\d+)?px)/i); + expect(actions.body).not.toMatch(/(?:#[0-9a-f]{3,8}|rgba?\(|\d+(?:\.\d+)?px)/i); + }); + + it("preserves the intentional <=768px edge-to-edge mobile layout", () => { + const mobile = mobileSection(); + expect(mobile).toMatch(/\.quick-entry-actions\s*\{[^}]*justify-content:\s*space-between/); + expect(mobile).toMatch(/\.quick-entry-options-group\s*\{[^}]*justify-content:\s*space-between/); + expect(mobile).toMatch(/\.quick-entry-primary-group\s*\{[^}]*justify-content:\s*space-between[^}]*width:\s*100%[^}]*margin-left:\s*0/); + }); + + it("renders sibling groups with Save last and no empty spacer shell", () => { + render(createElement(QuickEntryBox, { onCreate: vi.fn(), addToast: vi.fn(), tasks: [], projectId: "test-project" })); + const toggle = screen.getByTestId("quick-entry-toggle"); + if (toggle.getAttribute("aria-expanded") !== "true") fireEvent.click(toggle); + + const actions = screen.getByTestId("quick-entry-actions"); + expect([...actions.children].map((child) => child.getAttribute("data-testid"))).toEqual([ + "quick-entry-options-group", + "quick-entry-primary-group", + ]); + const actionButtons = [...actions.querySelectorAll("button[data-testid]")]; + expect(actionButtons.at(-1)?.getAttribute("data-testid")).toBe("quick-entry-save"); + expect([...actions.children].every((child) => child.textContent?.trim() || child.getAttribute("aria-label"))).toBe(true); + }); +}); diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index 063924a42b..c5c7060591 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -186,10 +186,19 @@ section under one line. flex-wrap: wrap; } -/* Consolidated action buttons in disclosure panel */ +/* +FNXC:QuickAddActionRow 2026-08-13-05:24: +The operator asked for the Quick Add bottom row to read as one centered desktop/tablet cluster. +Replace the split toolbar's greedy options group and auto-pushed primary group with base-scope +centering while preserving DOM order, grouping, and the action row's existing flex width. The +FN-7680/FN-7683/FN-8147 fixed-height and FN-7684/FN-8164 Save-never-clipped contracts remain +owned by their existing rules. At <=768px, the deliberate 2026-07-16-13:05 operator decision keeps +edge-to-edge `space-between`; do not unify these breakpoint layouts. +*/ .quick-entry-actions { display: flex; align-items: center; + justify-content: center; gap: var(--space-sm); flex-wrap: wrap; flex: 1 1 auto; @@ -200,10 +209,9 @@ FNXC:BoardComposer 2026-07-10-12:00: The action row is organized as two logical clusters instead of one undifferentiated wrap of chips: - `.quick-entry-options-group` — task options (workflow, optional steps, subtask, deps, models, node, agent), wrapping left-to-right with one consistent chip gap. -- `.quick-entry-primary-group` — attach + GitHub tracking + Priority + Fast + Save; margin-left auto - keeps it right-aligned so the distinct Save action always ends the row (and wraps as one unit on - narrow columns, never splitting Save from its neighbors). Priority and Fast are icon-only here; the - priority glyph follows the shared up/high, down/low, flag/normal, alert/urgent mapping. +- `.quick-entry-primary-group` — attach + GitHub tracking + Priority + Fast + Save; the primary + action stays last in DOM order. Priority and Fast are icon-only here; the priority glyph follows + the shared up/high, down/low, flag/normal, alert/urgent mapping. Chip HEIGHT consistency is already enforced by the FN-7680/FN-7683 fixed-box rule on `.quick-entry-actions .btn` above (descendant selector — still applies inside these wrappers); these rules only own grouping/spacing/alignment. @@ -213,7 +221,7 @@ rules only own grouping/spacing/alignment. align-items: center; flex-wrap: wrap; gap: var(--space-xs) var(--space-sm); - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; } @@ -249,7 +257,7 @@ line is strictly better than a clipped label. justify-content: flex-end; gap: var(--space-xs); flex: 0 0 auto; - margin-left: auto; + margin-left: 0; } .quick-entry-primary-group [data-testid="quick-entry-save"] { @@ -852,6 +860,12 @@ FN-7682 — the tokenized-CSS test forbids raw px in the workflow-selector rules intrinsic size={12}/size={14}. Desktop and the FN-7683 36px touch-target floor are unchanged. */ + /* + FNXC:QuickAddActionRow 2026-08-13-05:24: + Mobile is the deliberate exception to the centered desktop/tablet action row. The 2026-07-16-13:05 + operator decision rejected centering here because wrapped rows must share identical left and right + edges, so this later equal-specificity rule keeps `space-between` at <=768px. + */ .quick-entry-actions { justify-content: space-between; column-gap: var(--space-xs);