feat(FN-3072): fix design tokens, mobile touch targets, and plugin settings

This merge addresses FN-3072 design feedback by updating CSS design tokens and mobile touch targets in ModelOnboardingModal, PluginSlot, and SettingsModal components, while also adding tests for droid settings plugin integration states. FN-3137 refines MissionManager's back-button navigation test as

Fusion-Task-Id: FN-3072
This commit is contained in:
Fusion
2026-05-04 05:25:52 -07:00
committed by gsxdsm
parent 743cc38c0f
commit 6ea1384631
9 changed files with 187 additions and 46 deletions

View File

@@ -1121,7 +1121,7 @@
}
.model-onboarding-complete .success-icon {
color: var(--success, #22c55e);
color: var(--color-success);
}
.model-onboarding-complete p {
@@ -1191,11 +1191,11 @@
}
.model-onboarding-header {
padding: 14px 14px 10px;
padding: var(--space-md) var(--space-md) calc(var(--space-sm) + var(--space-xs) / 2);
}
.model-onboarding-steps {
padding: 12px 14px;
padding: var(--space-md);
}
.model-onboarding-step-connector {
@@ -1204,7 +1204,7 @@
}
.model-onboarding-content {
padding: 14px;
padding: var(--space-md);
}
.onboarding-provider-row {
@@ -1270,7 +1270,7 @@
name + description + status fit in ~3 short lines. */
.onboarding-provider-card__body {
flex: 1 1 0;
gap: 2px;
gap: calc(var(--space-xs) / 2);
}
.onboarding-provider-card__name {
@@ -1339,12 +1339,12 @@
flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 32px;
min-height: calc(var(--space-md) * 3);
}
.onboarding-apikey-input-row .btn {
flex: 0 0 auto;
min-height: 32px;
min-height: calc(var(--space-md) * 3);
/* Compact label so "Save" doesn't bloat the row. */
padding-inline: var(--space-md);
}
@@ -1374,7 +1374,7 @@
}
.model-onboarding-footer {
padding: 12px 14px;
padding: var(--space-md);
flex-wrap: wrap;
gap: var(--space-sm);
}
@@ -1414,12 +1414,12 @@
}
.onboarding-cta-card .cta-icon {
width: 40px;
height: 40px;
width: calc(var(--space-lg) * 2 + var(--space-sm));
height: calc(var(--space-lg) * 2 + var(--space-sm));
}
.onboarding-cta-card .cta-content strong {
font-size: 14px;
font-size: calc(var(--space-sm) + var(--space-xs) + var(--space-xs) / 2);
}
.onboarding-cta-card .cta-content span {

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import "./PluginSlot.css";
@@ -14,6 +15,14 @@ interface PluginSlotProps {
renderPlaceholder?: boolean;
}
function renderKnownPluginSlot(slotId: string, pluginId: string): ReactNode | null {
if (pluginId === "fusion-plugin-droid-runtime" && slotId === "settings-provider-card") {
return <DroidCliProviderCard compact authenticated={false} />;
}
return null;
}
/**
* Renders plugin slot registrations for a host surface.
*/
@@ -28,26 +37,37 @@ export function PluginSlot({ slotId, projectId, pluginIds, renderPlaceholder = t
pluginIds && pluginIds.length > 0 ? pluginIds.includes(entry.pluginId) : true,
);
if (matchingEntries.length === 0 || !renderPlaceholder) {
if (matchingEntries.length === 0) {
return null;
}
return (
<ErrorBoundary level="page">
<>
{matchingEntries.map((entry, index) => (
<section
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
className="plugin-slot-shell"
data-plugin-slot
data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId}
aria-label={entry.slot.label}
>
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
<p className="plugin-slot-shell__message">Extension content available.</p>
</section>
))}
{matchingEntries.map((entry, index) => {
const knownSlot = renderKnownPluginSlot(entry.slot.slotId, entry.pluginId);
if (knownSlot) {
return <div key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}>{knownSlot}</div>;
}
if (!renderPlaceholder) {
return null;
}
return (
<section
key={`${entry.pluginId}-${entry.slot.slotId}-${index}`}
className="plugin-slot-shell"
data-plugin-slot
data-slot-id={entry.slot.slotId}
data-plugin-id={entry.pluginId}
aria-label={entry.slot.label}
>
<p className="plugin-slot-shell__title">{entry.slot.label}</p>
<p className="plugin-slot-shell__message">Extension content available.</p>
</section>
);
})}
</>
</ErrorBoundary>
);

View File

@@ -15,16 +15,15 @@
their differing icon sizes (provider icon 16px vs. HelpCircle 13px). */
.settings-header-actions > .settings-github-star-btn,
.settings-header-actions > .btn {
height: 26px;
height: calc(var(--space-md) * 2 + var(--space-xs) / 2);
box-sizing: border-box;
}
@media (max-width: 768px) {
/* Keep settings header actions compact; override global mobile
`.btn-icon { min-height/min-width: 36px; }` inflation. */
/* Preserve mobile touch targets while keeping visuals compact via icon sizing/padding. */
.settings-header-actions > .btn-icon {
min-height: 26px;
min-width: 26px;
min-height: calc(var(--space-md) * 3);
min-width: calc(var(--space-md) * 3);
}
}
@@ -59,14 +58,14 @@
.settings-github-star-btn__action {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
gap: var(--space-xs);
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs) / 2);
}
.settings-github-star-btn__count {
display: inline-flex;
align-items: center;
padding: 4px 9px;
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs) / 4);
border-left: var(--btn-border-width) solid var(--border);
background: color-mix(in srgb, var(--surface) 60%, var(--card));
color: var(--text-muted);
@@ -91,6 +90,7 @@
overlay's default top padding so the modal actually fills the viewport,
and disable resize (touchscreen users can't drag the grip anyway). */
@media (max-width: 768px) {
.modal-overlay.settings-modal-overlay,
.modal-overlay:has(.settings-modal) {
padding-top: 0;
align-items: stretch;

View File

@@ -43,6 +43,7 @@ import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useNodes } from "../hooks/useNodes";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { useViewportMode } from "../hooks/useViewportMode";
import { NodeHealthDot } from "./NodeHealthDot";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
@@ -437,6 +438,7 @@ export function SettingsModal({
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const { nodes } = useNodes();
const { getSlotsForId } = usePluginUiSlots(projectId);
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView");
@@ -5025,6 +5027,9 @@ export function SettingsModal({
// auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli");
const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some(
(entry) => entry.pluginId === "fusion-plugin-droid-runtime",
);
const claudeCliCard = claudeCliProvider ? (
<ClaudeCliProviderCard
compact
@@ -5034,7 +5039,7 @@ export function SettingsModal({
}}
/>
) : null;
const droidCliCard = droidCliProvider ? (
const droidCliCard = droidCliProvider && !hasDroidPluginSlot ? (
<DroidCliProviderCard
compact
authenticated={droidCliProvider.authenticated}
@@ -5046,12 +5051,11 @@ export function SettingsModal({
const showAuthenticatedGroup =
authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false)
|| (droidCliProvider?.authenticated ?? false);
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot);
const showAvailableGroup =
unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|| (droidCliProvider && !droidCliProvider.authenticated);
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot);
return (
<>
<h4 className="settings-section-heading">Authentication</h4>
@@ -5314,7 +5318,7 @@ export function SettingsModal({
};
return (
<div className="modal-overlay open" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal-overlay open settings-modal-overlay" {...overlayDismissProps} role="dialog" aria-modal="true">
<div className="modal modal-lg settings-modal" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<div className="settings-modal-heading">

View File

@@ -1028,7 +1028,11 @@ describe("MissionManager", () => {
await waitFor(() => {
expect(screen.getByText("Mission event 50")).toBeDefined();
expect(screen.getByText("50 of 65")).toBeDefined();
expect(
screen.getByText("50 of 65", {
selector: ".mission-detail__activity-count",
}),
).toBeDefined();
expect(screen.getByTestId("mission-activity-load-more")).toBeDefined();
});
@@ -1036,7 +1040,9 @@ describe("MissionManager", () => {
await waitFor(() => {
expect(
screen.getByText((_, element) => element?.textContent?.includes("65 of 65") ?? false),
screen.getByText("65 of 65", {
selector: ".mission-detail__activity-count",
}),
).toBeDefined();
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
});

View File

@@ -5,6 +5,9 @@ import type { PluginUiSlotEntry } from "../../api";
import { usePluginUiSlots } from "../../hooks/usePluginUiSlots";
vi.mock("../../hooks/usePluginUiSlots");
vi.mock("../DroidCliProviderCard", () => ({
DroidCliProviderCard: () => <div data-testid="droid-cli-provider-card" />,
}));
function createSlotEntry(slotId: string, pluginId = "test-plugin"): PluginUiSlotEntry {
return {
@@ -119,7 +122,7 @@ describe("PluginSlot", () => {
expect(vi.mocked(usePluginUiSlots)).toHaveBeenCalledWith("proj-1");
});
it("suppresses placeholder rendering when renderPlaceholder is false", () => {
it("suppresses placeholder rendering when renderPlaceholder is false for unknown slots", () => {
const entry = createSlotEntry("settings-provider-card", "plugin-droid");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
@@ -135,6 +138,22 @@ describe("PluginSlot", () => {
expect(container.firstChild).toBeNull();
});
it("renders known droid settings slot even when placeholders are disabled", () => {
const entry = createSlotEntry("settings-provider-card", "fusion-plugin-droid-runtime");
vi.mocked(usePluginUiSlots).mockReturnValue({
slots: [entry],
getSlotsForId: vi.fn(() => [entry]),
loading: false,
error: null,
});
const { container } = render(
<PluginSlot slotId="settings-provider-card" renderPlaceholder={false} />,
);
expect(container.querySelector('[data-testid="droid-cli-provider-card"]')).not.toBeNull();
});
it("returns null for empty string slotId", () => {
const getSlotsForId = vi.fn(() => []);
vi.mocked(usePluginUiSlots).mockReturnValue({

View File

@@ -49,6 +49,9 @@ const mockGenerateShortLivedRemoteToken = vi.fn();
const mockFetchRemoteQr = vi.fn();
const mockFetchRemoteUrl = vi.fn();
const mockTriggerMemoryDreams = vi.fn();
const mockFetchPluginUiSlots = vi.fn();
const mockFetchDroidCliStatus = vi.fn();
const mockSetDroidCliEnabled = vi.fn();
const mockUseWorkspaceFileBrowser = vi.fn();
vi.mock("../../api", async (importOriginal) => {
@@ -98,6 +101,9 @@ vi.mock("../../api", async (importOriginal) => {
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
triggerMemoryDreams: (...args: unknown[]) => mockTriggerMemoryDreams(...args),
fetchPluginUiSlots: (...args: unknown[]) => mockFetchPluginUiSlots(...args),
fetchDroidCliStatus: (...args: unknown[]) => mockFetchDroidCliStatus(...args),
setDroidCliEnabled: (...args: unknown[]) => mockSetDroidCliEnabled(...args),
});
});
@@ -136,9 +142,6 @@ vi.mock("../PiExtensionsManager", () => ({
PiExtensionsManager: () => <div data-testid="pi-extensions-manager">Pi extensions content</div>,
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: () => <div data-testid="plugin-slot">Plugin slot content</div>,
}));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
@@ -361,6 +364,14 @@ describe("SettingsModal", () => {
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
mockTriggerMemoryDreams.mockResolvedValue({ success: true, summary: "done" });
mockFetchPluginUiSlots.mockResolvedValue([]);
mockFetchDroidCliStatus.mockResolvedValue({
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: false,
extension: { status: "ok" },
ready: false,
});
mockSetDroidCliEnabled.mockResolvedValue({ enabled: true, restartRequired: true });
mockUseWorkspaceFileBrowser.mockReturnValue({
entries: [],
currentPath: ".",
@@ -1056,6 +1067,78 @@ describe("SettingsModal", () => {
});
});
describe("Droid plugin Settings integration", () => {
it.each([
{
name: "unavailable/not enabled",
status: {
binary: { available: false, reason: "`droid` not found on PATH", probeDurationMs: 9 },
enabled: false,
extension: { status: "ok" },
ready: false,
},
expectedText: "not found on PATH",
},
{
name: "enabled but not ready",
status: {
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: true,
extension: { status: "ok" },
ready: false,
},
expectedText: "Enabled. Validating…",
},
{
name: "connected and ready",
status: {
binary: { available: true, version: "1.2.3", binaryPath: "/usr/local/bin/droid", probeDurationMs: 9 },
enabled: true,
extension: { status: "ok" },
ready: true,
},
expectedText: "✓ Connected — 1.2.3",
},
])("renders plugin-driven droid card state: $name", async ({ status, expectedText }) => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
mockFetchPluginUiSlots.mockResolvedValueOnce([
{
pluginId: "fusion-plugin-droid-runtime",
slot: {
slotId: "settings-provider-card",
label: "Droid CLI Provider",
componentPath: "./components/settings-provider-card.js",
},
},
]);
mockFetchDroidCliStatus.mockResolvedValueOnce(status);
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
expect(screen.getByText(new RegExp(expectedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument();
});
it("renders legacy droid auth card only when plugin slot is not present", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "droid-cli", name: "Factory AI (via Droid CLI)", authenticated: false, type: "cli" }],
});
mockFetchPluginUiSlots.mockResolvedValueOnce([]);
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByTestId("droid-cli-provider-card")).toBeInTheDocument();
expect(screen.getAllByTestId("droid-cli-provider-card")).toHaveLength(1);
});
});
describe("Plugins section navigation", () => {
it("does not render a standalone Pi Extensions sidebar item", async () => {
renderModal();
@@ -1090,7 +1173,6 @@ describe("SettingsModal", () => {
await userEvent.click(await screen.findByRole("button", { name: /Plugins$/ }));
expect(await screen.findByTestId("plugin-manager")).toBeInTheDocument();
expect(screen.getByTestId("plugin-slot")).toBeInTheDocument();
expect(screen.queryByTestId("pi-extensions-manager")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: "Pi Extensions" }));
@@ -1099,7 +1181,6 @@ describe("SettingsModal", () => {
expect(screen.getByTestId("pi-extensions-manager")).toBeInTheDocument();
});
expect(screen.queryByTestId("plugin-manager")).not.toBeInTheDocument();
expect(screen.queryByTestId("plugin-slot")).not.toBeInTheDocument();
expect(screen.getByRole("tabpanel", { name: "Pi Extensions" })).toBeVisible();
expect(document.getElementById("plugins-panel-fusion-plugins")).toHaveAttribute("hidden");
});