FN-7763: fix oversized icons and spacing on MCP servers settings page
Fixes inline lucide icons on the MCP servers settings card that rendered oversized (24px default) and misaligned since .btn > svg is intentionally unsized globally. - Size inline lucide icons in McpServersCard with new --icon-size-sm/md-aligned constants (14px/16px) instead of relying on unsized default - Apply sizing to Add secret reference, Add server, Scan again, Test, Override, Edit, Upload JSON, and Copy Fusion MCP JSON buttons - Add regression test coverage in SettingsModal.mcp.test.tsx asserting icon sizes - Add changeset documenting the fix Files changed: .changeset/fn-7763-mcp-layout.md | 7 +++++ .../__tests__/SettingsModal.mcp.test.tsx | 34 ++++++++++++++++++++++ .../settings/sections/McpServersCard.tsx | 23 ++++++++++----- 3 files changed, 56 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7763 Fusion-Task-Lineage: 163dcba0-5189-49dd-9100-8a0bd3bc92b1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7763-mcp-layout.md
Normal file
7
.changeset/fn-7763-mcp-layout.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix oversized icons and spacing on the MCP servers settings page.
|
||||
category: fix
|
||||
dev: McpServersCard inline lucide icons now use --icon-size-sm/md token values; .btn > svg is unsized globally so they previously fell back to lucide's 24px default.
|
||||
@@ -64,6 +64,13 @@ function mockFetch(statusByName: Record<string, { status: "valid" | "unreachable
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function expectButtonIconSize(button: HTMLElement, size: "14" | "16") {
|
||||
const icon = button.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("width", size);
|
||||
expect(icon).toHaveAttribute("height", size);
|
||||
}
|
||||
|
||||
function renderCard(options: { scope: McpSettingsScope; form?: Settings; globalSettings?: Pick<GlobalSettings, "mcpServers"> | null }) {
|
||||
let currentForm: Settings = options.form ?? ({} as Settings);
|
||||
const addToast = vi.fn();
|
||||
@@ -144,6 +151,33 @@ describe("MCP Settings UI", () => {
|
||||
expect(screen.getByText("No MCP servers configured.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["global", "project"] as const)("sizes MCP card inline button icons in %s scope", async (scope) => {
|
||||
mockFetch({}, { [scope]: discoveredResponse(scope) });
|
||||
renderCard({
|
||||
scope,
|
||||
form: { mcpServers: { enabled: true, servers: [{ name: `${scope}-local`, transport: "stdio", command: "node" }] } } as Settings,
|
||||
globalSettings: scope === "project" ? { mcpServers: { enabled: true, servers: [{ name: "shared", transport: "stdio", command: "node" }] } } : undefined,
|
||||
});
|
||||
|
||||
expectButtonIconSize(screen.getByRole("button", { name: /Add server/i }), "16");
|
||||
const discovery = await screen.findByTestId(`mcp-discovery-${scope}`);
|
||||
expectButtonIconSize(within(discovery).getByRole("button", { name: /Scan again/i }), "14");
|
||||
expectButtonIconSize(screen.getByRole("button", { name: /Upload JSON/i }), "14");
|
||||
expectButtonIconSize(screen.getByRole("button", { name: /Copy Fusion MCP JSON/i }), "14");
|
||||
|
||||
const localRow = await screen.findByTestId(`mcp-server-row-${scope}-local`);
|
||||
expectButtonIconSize(within(localRow).getByRole("button", { name: /Test/i }), "14");
|
||||
expectButtonIconSize(within(localRow).getByRole("button", { name: /^Edit$/i }), "14");
|
||||
if (scope === "project") {
|
||||
const inheritedRow = await screen.findByTestId("mcp-server-row-shared");
|
||||
expectButtonIconSize(within(inheritedRow).getByRole("button", { name: /Override/i }), "14");
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add server/i }));
|
||||
const editor = await screen.findByTestId("mcp-server-editor");
|
||||
expectButtonIconSize(within(editor).getByRole("button", { name: /Add secret reference/i }), "14");
|
||||
});
|
||||
|
||||
it.each(["stdio", "sse", "streamable-http"] as const)("adds, edits, and removes a %s server without plaintext secrets", async (transport) => {
|
||||
const { getForm } = renderCard({ scope: "project", form: {} as Settings });
|
||||
await screen.findByText("No MCP servers configured.");
|
||||
|
||||
@@ -95,6 +95,13 @@ export interface McpServersCardProps {
|
||||
}
|
||||
|
||||
const EMPTY_MCP_SETTINGS: McpServersSettings = { enabled: false, servers: [] };
|
||||
/*
|
||||
FNXC:McpConfig 2026-07-09-00:00:
|
||||
MCP-card buttons render inline lucide icons at the standard --icon-size-sm/--icon-size-md token values so icons align with button labels and match the rest of Settings.
|
||||
The global .btn > svg selector is intentionally unsized, so inline icons must opt in here or they fall back to lucide's 24px default.
|
||||
*/
|
||||
const MCP_BUTTON_ICON_SIZE_SM = 14;
|
||||
const MCP_BUTTON_ICON_SIZE_MD = 16;
|
||||
let rowCounter = 0;
|
||||
|
||||
function nextRowId(): string {
|
||||
@@ -518,7 +525,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
{row.required ? null : <button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeSensitive", "Remove secret reference")} onClick={() => setEditor((current) => current && { ...current, [field]: current[field].filter((candidate) => candidate.id !== row.id) })}><Trash2 aria-hidden="true" /></button>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => setEditor((current) => current && { ...current, [field]: [...current[field], { id: nextRowId(), key: "", secretRef: "", scope, createKey: "", createValue: "", required: false }] })}><Plus aria-hidden="true" /> {t("settings.mcp.addSecretRef", "Add secret reference")}</button>
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => setEditor((current) => current && { ...current, [field]: [...current[field], { id: nextRowId(), key: "", secretRef: "", scope, createKey: "", createValue: "", required: false }] })}><Plus aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("settings.mcp.addSecretRef", "Add secret reference")}</button>
|
||||
{secretsError ? <p className="form-error">{secretsError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -530,7 +537,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
<h5 className="mcp-servers-card__title">{scope === "global" ? t("settings.mcp.globalTitle", "Global MCP servers") : t("settings.mcp.projectTitle", "Project MCP servers")}</h5>
|
||||
<p className="mcp-servers-card__description">{scope === "global" ? t("settings.mcp.globalDescription", "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.") : t("settings.mcp.projectDescription", "Configure project-specific MCP servers, overrides, and disabled inherited servers.")}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary touch-target" onClick={() => { setEditor(draftFromServer()); setEditorError(null); }}><Plus aria-hidden="true" /> {t("settings.mcp.addServer", "Add server")}</button>
|
||||
<button type="button" className="btn btn-primary touch-target" onClick={() => { setEditor(draftFromServer()); setEditorError(null); }}><Plus aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_MD} /> {t("settings.mcp.addServer", "Add server")}</button>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label mcp-enabled-toggle">
|
||||
@@ -545,7 +552,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
<h6>{t("settings.mcp.discoveryTitle", "Discovered on this machine")}</h6>
|
||||
<p>{t("settings.mcp.discoveryDescription", "Read-only scan of Claude, Cursor, Windsurf, and VS Code MCP config files. Add a server to enable it in Fusion.")}</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void scanDiscoveredServers()} disabled={discoveryLoading}><RefreshCw aria-hidden="true" /> {discoveryLoading ? t("settings.mcp.scanning", "Scanning…") : t("settings.mcp.scanAgain", "Scan again")}</button>
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void scanDiscoveredServers()} disabled={discoveryLoading}><RefreshCw aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {discoveryLoading ? t("settings.mcp.scanning", "Scanning…") : t("settings.mcp.scanAgain", "Scan again")}</button>
|
||||
</div>
|
||||
{discoveryError ? <p className="form-error">{discoveryError}</p> : null}
|
||||
{(discovered?.errors?.length ?? 0) > 0 ? <div className="mcp-discovery__notes" role="note">{discovered?.errors?.map((error) => <p key={error}>{error}</p>)}</div> : null}
|
||||
@@ -587,10 +594,10 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
<p className={`mcp-validation-status mcp-validation-status--${validation.status}`} data-testid={`mcp-validation-${server.name}`} aria-live="polite"><span className={getValidateDotClass(validation.status)} aria-hidden="true" /> <span className="mcp-validation-status__badge">{validation.status === "idle" ? t("settings.mcp.notTested", "Not tested") : getValidationLabel(validation.status)}</span>{validation.message ? <span>{validation.message}</span> : null}</p>
|
||||
</div>
|
||||
<div className="mcp-server-row__actions">
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void validateServer(server)} disabled={validation.status === "pending"}><Play aria-hidden="true" /> {validation.status === "pending" ? t("settings.mcp.testing", "Testing…") : t("settings.mcp.test", "Test")}</button>
|
||||
{state === "inherited" ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" /> {t("settings.mcp.override", "Override")}</button> : null}
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void validateServer(server)} disabled={validation.status === "pending"}><Play aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {validation.status === "pending" ? t("settings.mcp.testing", "Testing…") : t("settings.mcp.test", "Test")}</button>
|
||||
{state === "inherited" ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("settings.mcp.override", "Override")}</button> : null}
|
||||
{state === "inherited" ? <button type="button" className="btn btn-warning btn-sm touch-target" onClick={() => disableInheritedServer(server.name)}>{t("settings.mcp.disableInherited", "Disable")}</button> : null}
|
||||
{editable ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" /> {t("actions.edit", "Edit")}</button> : null}
|
||||
{editable ? <button type="button" className="btn btn-sm touch-target" onClick={() => { setEditor(draftFromServer(server)); setEditorError(null); }}><Pencil aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("actions.edit", "Edit")}</button> : null}
|
||||
{editable ? <button type="button" className="btn btn-icon touch-target" aria-label={t("settings.mcp.removeServer", "Remove {{name}}", { name: server.name })} onClick={() => removeServer(server.name)}><Trash2 aria-hidden="true" /></button> : null}
|
||||
</div>
|
||||
</article>
|
||||
@@ -618,12 +625,12 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
|
||||
<h6>{t("settings.mcp.import", "Import")}</h6>
|
||||
<textarea className="input mcp-json-textarea" value={importText} onChange={(event) => setImportText(event.target.value)} placeholder={t("settings.mcp.importPlaceholder", "Paste Claude Desktop mcpServers JSON")} />
|
||||
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => { const file = event.target.files?.[0]; if (!file) return; void file.text().then(setImportText); event.target.value = ""; }} />
|
||||
<div className="mcp-inline-actions"><button type="button" className="btn btn-sm touch-target" onClick={() => fileInputRef.current?.click()}><Upload aria-hidden="true" /> {t("settings.mcp.uploadJson", "Upload JSON")}</button><button type="button" className="btn btn-sm touch-target" onClick={() => void importServers(importText)}>{t("settings.mcp.import", "Import")}</button></div>
|
||||
<div className="mcp-inline-actions"><button type="button" className="btn btn-sm touch-target" onClick={() => fileInputRef.current?.click()}><Upload aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("settings.mcp.uploadJson", "Upload JSON")}</button><button type="button" className="btn btn-sm touch-target" onClick={() => void importServers(importText)}>{t("settings.mcp.import", "Import")}</button></div>
|
||||
{importError ? <p className="form-error">{importError}</p> : null}
|
||||
</div>
|
||||
<div className="mcp-import-export__pane">
|
||||
<h6>{t("settings.mcp.export", "Export")}</h6>
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void exportServers()}><Download aria-hidden="true" /> {t("settings.mcp.copyExport", "Copy Fusion MCP JSON")}</button>
|
||||
<button type="button" className="btn btn-sm touch-target" onClick={() => void exportServers()}><Download aria-hidden="true" size={MCP_BUTTON_ICON_SIZE_SM} /> {t("settings.mcp.copyExport", "Copy Fusion MCP JSON")}</button>
|
||||
{exportText ? <a className="btn btn-sm touch-target" download="fusion-mcp-servers.json" href={`data:application/json;charset=utf-8,${encodeURIComponent(exportText)}`}>{t("settings.mcp.downloadExport", "Download JSON")}</a> : null}
|
||||
{exportText ? <textarea className="input mcp-json-textarea" readOnly value={exportText} aria-label={t("settings.mcp.exportedJson", "Exported MCP JSON")} /> : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user