FN-9024: center Quick Add action row on tablet

Center Quick Add actions as a single desktop and tablet cluster while preserving the mobile layout.

- Center the base action row and remove greedy split alignment.
- Retain the <=768px edge-to-edge mobile layout.
- Add a regression test and a patch changeset.

Files changed:
 .changeset/fn-9024-quick-add-center-action-row.md  |   7 ++
 .../quick-entry-action-row-centering.css.test.ts   | 138 +++++++++++++++++++++
 .../dashboard/app/components/QuickEntryBox.css     |  28 +++--
 3 files changed, 166 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-9024

Fusion-Task-Lineage: e6c6d9b5-e9cf-4d37-9d62-fedd1986d7cc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 22:40:12 -07:00
parent c791c0b199
commit 0e98d9b213
3 changed files with 166 additions and 7 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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);