fix(ui): stop the add-provider modal from shifting while it loads (#13179)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-27 20:53:00 +01:00
1 parent 75c9a6bf51
commit dcd3d5a0fb
22 files changed
+799 -151

No files matched your search

@@ -0,0 +1,68 @@
import { describe, expect, vi } from "vitest";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { LighthouseConnectHelp } from "./lighthouse-connect-help";
vi.mock(import("@/lib/shared/env"), async (importOriginal) => ({
...(await importOriginal()),
isCloud: () => true,
}));
vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
getLighthouseV2Configurations: () =>
Promise.resolve({ data: [{ id: "config-1", connected: true }] }),
}));
const DESCRIPTION =
"Lighthouse AI walks you through it with Prowler's docs, right beside this form.";
// Like the modal frame: a marked size container, so lg: follows its width.
function renderInWizardFrame(width: number) {
return render(
<div data-responsive-container className="@container" style={{ width }}>
<LighthouseConnectHelp
docsLink="https://goto.prowler.com/provider-aws"
docsDestination="Provider"
providerType={null}
mode="add"
stepName="connect"
stepLabel="Link a Provider"
isOrganizationsFlow={false}
/>
</div>,
);
}
describe("Lighthouse help in the wizard header", () => {
it("keeps to a single line while the wizard stacks its steps", async () => {
// When
const screen = await renderInWizardFrame(720);
// Then
const callout = screen.getByRole("region", {
name: "Help from Lighthouse AI",
});
await expect
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
.toBeVisible();
await expect
.element(screen.getByText("Not sure how to connect your cloud?"))
.toBeVisible();
await expect.element(screen.getByText(DESCRIPTION)).not.toBeVisible();
expect(
callout.element().getBoundingClientRect().height,
).toBeLessThanOrEqual(72);
});
it("explains what Lighthouse AI does beside a wide wizard", async () => {
// When
const screen = await renderInWizardFrame(1200);
// Then
await expect
.element(screen.getByRole("button", { name: "Ask Lighthouse AI" }))
.toBeVisible();
await expect.element(screen.getByText(DESCRIPTION)).toBeVisible();
});
});
@@ -8,6 +8,7 @@ import {
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
@@ -73,6 +74,17 @@ function connectedConfiguration(connected: boolean) {
return { data: [{ id: "config-1", connected }] };
}
// An LLM check the test settles by hand.
function pendingCheck() {
let settle: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
const promise = new Promise((resolve) => {
settle = resolve;
});
return { promise, settle };
}
describe("LighthouseConnectHelp", () => {
beforeEach(() => {
getConfigurationsMock.mockResolvedValue(connectedConfiguration(true));
@@ -82,6 +94,7 @@ describe("LighthouseConnectHelp", () => {
});
useProviderWizardStore.getState().reset();
useLighthouseContextStore.setState({ contributions: {} });
useLighthouseLlmStore.setState({ hasConnectedLlm: null, latestCheck: 0 });
});
it("invites the user to ask Lighthouse AI about the provider they picked", async () => {
@@ -99,6 +112,142 @@ describe("LighthouseConnectHelp", () => {
);
});
it("holds the callout's place while it checks for a connected LLM", async () => {
// Given
let settleCheck: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
getConfigurationsMock.mockReturnValue(
new Promise((resolve) => {
settleCheck = resolve;
}),
);
const funnelEvents = recordFunnelEvents();
// When
renderHelp("aws");
// Then: a placeholder, never the docs line the callout would replace
expect(
screen.getByRole("status", { name: "Loading Lighthouse AI help" }),
).toBeVisible();
expect(
screen.queryByRole("link", { name: /AWS documentation/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
// When
await act(async () => settleCheck(connectedConfiguration(true)));
// Then
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
expect(funnelEvents.stop()).toHaveLength(1);
});
it("shows the last answer at once when the wizard opens again, then refreshes it", async () => {
// Given: an earlier opening found a connected LLM
const firstOpening = renderHelp("aws");
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
firstOpening.unmount();
let settleCheck: (
result: ReturnType<typeof connectedConfiguration>,
) => void = () => {};
getConfigurationsMock.mockReturnValue(
new Promise((resolve) => {
settleCheck = resolve;
}),
);
// When
renderHelp("aws");
// Then: no placeholder while the fresh check runs
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
// When: the LLM was disconnected meanwhile
await act(async () => settleCheck(connectedConfiguration(false)));
// Then
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
});
it("ignores an older check that answers after a newer opening", async () => {
// Given: the wizard closes while its check runs, then opens again
const olderCheck = pendingCheck();
const newerCheck = pendingCheck();
getConfigurationsMock
.mockReturnValueOnce(olderCheck.promise)
.mockReturnValueOnce(newerCheck.promise);
const firstOpening = renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledOnce());
firstOpening.unmount();
renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
// When: the newer check answers first, then the older one
await act(async () => newerCheck.settle(connectedConfiguration(false)));
await act(async () => olderCheck.settle(connectedConfiguration(true)));
// Then
expect(
screen.queryByRole("button", { name: "Ask Lighthouse AI" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
});
it("keeps the remembered answer when a later check fails", async () => {
// Given: an earlier opening found a connected LLM
const firstOpening = renderHelp("aws");
await screen.findByRole("button", { name: "Ask Lighthouse AI" });
firstOpening.unmount();
getConfigurationsMock.mockRejectedValueOnce(new Error("network"));
// When
renderHelp("aws");
await waitFor(() => expect(getConfigurationsMock).toHaveBeenCalledTimes(2));
await act(() => Promise.resolve());
// Then: a failed refresh does not swap the callout for the docs line
expect(
screen.getByRole("button", { name: "Ask Lighthouse AI" }),
).toBeVisible();
});
it("falls back to the documentation link when the check fails", async () => {
// Given
getConfigurationsMock.mockRejectedValue(new Error("network"));
// When
renderHelp("aws");
// Then
expect(
await screen.findByRole("link", { name: /AWS documentation/i }),
).toBeVisible();
expect(
screen.queryByRole("status", { name: "Loading Lighthouse AI help" }),
).not.toBeInTheDocument();
});
it("keeps only the documentation link while Lighthouse AI is not set up", async () => {
// Given
getConfigurationsMock.mockResolvedValue(connectedConfiguration(false));
@@ -15,6 +15,7 @@ import {
LighthouseCalloutIcon,
LighthouseCalloutTitle,
} from "@/components/shadcn/lighthouse-callout";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { getVisibleSidePanelTabs } from "@/components/side-panel/side-panel-tabs";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useMountEffect } from "@/hooks/use-mount-effect";
@@ -25,6 +26,7 @@ import {
PROVIDER_FUNNEL_STEP,
} from "@/lib/provider-funnel/provider-funnel-events";
import { SIDE_PANEL_PUSH_MEDIA_QUERY } from "@/lib/ui-layout";
import { useLighthouseLlmStore } from "@/store/lighthouse-llm/store";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills";
@@ -40,6 +42,13 @@ const CONNECTION_GUIDE_SKILL = getSkillById(
LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE,
);
const HELP_STATE = {
DOCS: "docs",
CHECKING: "checking",
LIGHTHOUSE: "lighthouse",
} as const;
type HelpState = (typeof HELP_STATE)[keyof typeof HELP_STATE];
interface LighthouseConnectHelpProps {
docsLink: string;
docsDestination: string;
@@ -61,7 +70,7 @@ export function LighthouseConnectHelp({
stepLabel,
isOrganizationsFlow,
}: LighthouseConnectHelpProps) {
const isLighthouseReady = useIsLighthouseReady();
const helpState = useHelpState();
const isGuiding = useSidePanelStore(
(state) => state.isOpen && state.selectedTab === SIDE_PANEL_TAB.AI_CHAT,
);
@@ -73,7 +82,7 @@ export function LighthouseConnectHelp({
(state) => state.awsConnectDraft?.method,
);
if (!isLighthouseReady || !CONNECTION_GUIDE_SKILL) {
if (helpState === HELP_STATE.DOCS || !CONNECTION_GUIDE_SKILL) {
return (
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm">
<Info className="size-4 shrink-0" />
@@ -120,17 +129,13 @@ export function LighthouseConnectHelp({
? getProviderDisplayName(providerType)
: null;
const copy = getHelpCopy({ isGuiding, providerName });
return (
<LighthouseCallout
aria-label="Help from Lighthouse AI"
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
>
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
const calloutContent = (
<>
<LighthouseCalloutIcon />
<LighthouseCalloutBody>
<LighthouseCalloutTitle>{copy.title}</LighthouseCalloutTitle>
<LighthouseCalloutDescription>
{/* A stacked wizard keeps the list in view: title and actions only. */}
<LighthouseCalloutDescription className="hidden lg:block">
{copy.description}
</LighthouseCalloutDescription>
</LighthouseCalloutBody>
@@ -145,6 +150,32 @@ export function LighthouseConnectHelp({
{copy.action}
</LighthouseCalloutAction>
</LighthouseCalloutActions>
</>
);
if (helpState === HELP_STATE.CHECKING) {
return (
<div
role="status"
aria-label="Loading Lighthouse AI help"
className="relative mt-2"
>
{/* Invisible copy sizes the placeholder exactly like the callout. */}
<LighthouseCallout aria-hidden inert className="invisible">
{calloutContent}
</LighthouseCallout>
<Skeleton className="absolute inset-0 rounded-2xl" />
</div>
);
}
return (
<LighthouseCallout
aria-label="Help from Lighthouse AI"
className="animate-in fade-in-0 slide-in-from-top-1 mt-2 duration-300"
>
<HelpShownSignal providerType={providerType} wizardStep={stepName} />
{calloutContent}
{/* Follow-ups typed in the panel carry the wizard's current state. */}
<LighthouseContextContributor
contributorId="provider-wizard"
@@ -196,34 +227,47 @@ function getHelpCopy({ isGuiding, providerName }: HelpCopyInput) {
// Lighthouse AI can only help beside the wizard when the panel hosts the chat
// (cloud), the panel pushes instead of covering it, and an LLM is connected.
function useIsLighthouseReady(): boolean {
function useHelpState(): HelpState {
const canHostChat = getVisibleSidePanelTabs().some(
(tab) => tab.id === SIDE_PANEL_TAB.AI_CHAT,
);
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
// Local state needed: whether an LLM is connected is fetched once per open.
const [hasConnectedLlm, setHasConnectedLlm] = useState(false);
// Kept across openings; every opening still refreshes it.
const hasConnectedLlm = useLighthouseLlmStore(
(state) => state.hasConnectedLlm,
);
const startCheck = useLighthouseLlmStore((state) => state.startCheck);
const settleCheck = useLighthouseLlmStore((state) => state.settleCheck);
// Local state needed: with nothing remembered, a failed check shows the docs.
const [checkFailed, setCheckFailed] = useState(false);
useMountEffect(() => {
if (!canHostChat) return;
let isMounted = true;
const check = startCheck();
// Lazy: the Lighthouse actions only load where the chat can exist (cloud).
import("@/app/(prowler)/lighthouse/_actions")
.then(({ getLighthouseV2Configurations }) =>
getLighthouseV2Configurations(),
)
.then((result) => {
if (!isMounted || "error" in result) return;
setHasConnectedLlm(
result.data.some((configuration) => configuration.connected),
);
})
// Optional help: a failed check just leaves the docs link in place.
.catch(() => {});
.then((result) =>
"error" in result
? null
: result.data.some((configuration) => configuration.connected),
)
// Optional help: a failed check keeps the last answer, else the docs.
.catch(() => null)
.then((connected) => {
if (connected !== null) settleCheck(check, connected);
else if (isMounted) setCheckFailed(true);
});
return () => {
isMounted = false;
};
});
return canHostChat && isPushViewport && hasConnectedLlm;
if (!canHostChat || !isPushViewport) return HELP_STATE.DOCS;
if (hasConnectedLlm === null)
return checkFailed ? HELP_STATE.DOCS : HELP_STATE.CHECKING;
return hasConnectedLlm ? HELP_STATE.LIGHTHOUSE : HELP_STATE.DOCS;
}
@@ -154,8 +154,9 @@ async function enterAccountDetails() {
describe("provider wizard account creation", () => {
beforeEach(() => {
// Registry discovery only runs in Cloud.
// Registry discovery only runs in Cloud with Registry enabled.
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.stubEnv("UI_REGISTRY_ENABLED", "true");
useProviderWizardStore.getState().reset();
resetToasts();
getInstalledRegistryProviderOptions.mockResolvedValue({
@@ -525,44 +526,45 @@ describe("provider wizard account creation", () => {
);
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
await screen.findByText("Registry providers could not be loaded");
expect(
screen.getByRole("option", { name: /Amazon Web Services/ }),
await screen.findByRole("option", { name: /Amazon Web Services/ }),
).toBeVisible();
// Then: an unanswered discovery cannot vouch for Registry availability.
expect(
screen.queryByRole("tab", { name: "Registry" }),
).not.toBeInTheDocument();
// When
await user.click(screen.getByRole("tab", { name: "Registry" }));
await user.click(
screen.getByRole("button", { name: "Retry Registry providers" }),
await screen.findByRole("button", { name: "Retry Registry providers" }),
);
// Then
expect(
await screen.findByRole("option", { name: "Acme Cloud Registry" }),
).toBeVisible();
expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible();
expect(
screen.queryByText("Registry providers could not be loaded"),
).not.toBeInTheDocument();
});
it("keeps the Registry tab with a retry when eligible discovery fails", async () => {
it("shows the retry inside the Registry tab when eligible discovery fails", async () => {
// Given: Cloud with Registry enabled, but the catalog read failed.
getInstalledRegistryProviderOptions.mockResolvedValueOnce({
status: "error",
});
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
await screen.findByText("Registry providers could not be loaded");
// When
await user.click(screen.getByRole("tab", { name: "Registry" }));
await user.click(await screen.findByRole("tab", { name: "Registry" }));
// Then
expect(screen.getByText("No Registry providers available.")).toBeVisible();
expect(
await screen.findByText("Registry providers could not be loaded"),
).toBeVisible();
expect(
screen.getByRole("button", { name: "Retry Registry providers" }),
).toBeVisible();
expect(
screen.queryByText("No Registry providers available."),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { render } from "@/__tests__/render-browser";
import { WizardStepper } from "./wizard-stepper";
// Like the modal frame: a marked size container, so lg: follows its width.
function renderInWizardFrame(width: number) {
return render(
<div data-responsive-container className="@container" style={{ width }}>
<WizardStepper currentStep={1} />
</div>,
);
}
function boxOf(element: Element) {
const { width, height } = element.getBoundingClientRect();
return { width, height };
}
describe("wizard stepper", () => {
it("fits on one row naming only the current step while the wizard stacks", async () => {
// When
const screen = await renderInWizardFrame(720);
// Then
const progress = screen
.getByRole("navigation", { name: "Wizard progress" })
.element();
expect(boxOf(progress).height).toBeLessThanOrEqual(40);
await expect
.element(screen.getByText("Authenticate Credentials"))
.toBeVisible();
// Other steps keep their names for assistive tech but take no room.
await expect.element(
screen.getByRole("navigation", { name: "Wizard progress" }),
).toMatchAriaInlineSnapshot(`
- navigation "Wizard progress":
- text: Link a Provider Authenticate Credentials Validate Connection Launch Scan
`);
const otherStep = screen.getByText("Link a Provider").element();
expect(otherStep.checkVisibility()).toBe(true);
// The block holding the name is clipped to 1px; the text inside overflows it.
expect(boxOf(otherStep.closest("div")!).width).toBeLessThanOrEqual(1);
await expect
.element(
screen.getByText(
"Authorize a secure connection between Prowler and your provider.",
),
)
.not.toBeVisible();
expect(
screen
.getByText("Authenticate Credentials")
.element()
.closest("[aria-current]"),
).toHaveAttribute("aria-current", "step");
});
it("lists every step with its description beside a wide wizard", async () => {
// When
const screen = await renderInWizardFrame(1200);
// Then
await expect
.element(
screen.getByText(
"Enter the provider details you would like to add in Prowler.",
),
)
.toBeVisible();
await expect.element(screen.getByText("Link a Provider")).toBeVisible();
expect(
boxOf(
screen.getByRole("navigation", { name: "Wizard progress" }).element(),
).height,
).toBeGreaterThan(300);
});
});
@@ -1,7 +1,7 @@
"use client";
import { CircleCheckBig, FolderGit2, KeyRound, Rocket } from "lucide-react";
import { ReactElement } from "react";
import { Fragment, ReactElement } from "react";
import { ProwlerShort } from "@/components/icons/prowler/ProwlerIcons";
import { cn } from "@/lib/utils";
@@ -67,6 +67,13 @@ export function getActiveStepIndex(
return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1));
}
const CONNECTOR_DIRECTION = {
VERTICAL: "to bottom",
HORIZONTAL: "to right",
} as const;
type ConnectorDirection =
(typeof CONNECTOR_DIRECTION)[keyof typeof CONNECTOR_DIRECTION];
export function WizardStepper({
currentStep,
stepOffset = 0,
@@ -78,42 +85,71 @@ export function WizardStepper({
steps.length,
);
// Below lg the wizard stacks: one row of steps, naming only the current one.
return (
<nav aria-label="Wizard progress" className="flex flex-col gap-0">
<nav
aria-label="Wizard progress"
className="flex items-center gap-2 lg:flex-col lg:items-stretch lg:gap-0"
>
{steps.map((step, index) => {
const isComplete = index < activeVisualStep;
const isActive = index === activeVisualStep;
const isInactive = index > activeVisualStep;
const hasNext = index < steps.length - 1;
return (
<div key={step.label} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{index < steps.length - 1 && (
<StepConnector isComplete={isComplete} />
<Fragment key={step.label}>
<div
aria-current={isActive ? "step" : undefined}
className={cn(
"flex min-w-0 items-center gap-2 lg:items-start lg:gap-3",
!isActive && "shrink-0",
)}
</div>
>
<div className="flex flex-col items-center">
<StepCircle
isComplete={isComplete}
isActive={isActive}
icon={step.icon}
/>
{hasNext && (
<StepConnector
isComplete={isComplete}
className="hidden h-14 w-px lg:block"
direction={CONNECTOR_DIRECTION.VERTICAL}
/>
)}
</div>
<div className="flex flex-col gap-1 pt-[10px]">
<span
<div
className={cn(
"text-lg leading-7 font-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
"min-w-0 flex-col gap-1 lg:flex lg:pt-[10px]",
isActive ? "flex" : "sr-only lg:not-sr-only",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary text-xs leading-5">
{step.description}
</p>
<span
className={cn(
"truncate text-sm font-normal lg:overflow-visible lg:text-lg lg:leading-7 lg:whitespace-normal",
isActive && "text-text-neutral-primary",
isComplete && "text-text-neutral-primary",
isInactive && "text-text-neutral-tertiary",
)}
>
{step.label}
</span>
<p className="text-text-neutral-secondary hidden text-xs leading-5 lg:block">
{step.description}
</p>
</div>
</div>
</div>
{hasNext && (
<StepConnector
isComplete={isComplete}
className="h-px min-w-3 flex-1 lg:hidden"
direction={CONNECTOR_DIRECTION.HORIZONTAL}
/>
)}
</Fragment>
);
})}
</nav>
@@ -126,41 +162,58 @@ interface StepCircleProps {
icon: IconComponent;
}
const CIRCLE_SIZE = "size-8 lg:size-[44px]";
function StepCircle({ isComplete, isActive, icon: Icon }: StepCircleProps) {
if (isComplete) {
return (
<div className="bg-button-primary-press flex size-[44px] shrink-0 items-center justify-center rounded-full">
<CircleCheckBig className="text-bg-neutral-primary size-6" />
<div
className={`bg-button-primary-press flex shrink-0 items-center justify-center rounded-full ${CIRCLE_SIZE}`}
>
<CircleCheckBig className="text-bg-neutral-primary size-4 lg:size-6" />
</div>
);
}
if (isActive) {
return (
<div className="border-border-input-primary-pressed bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<div
className={`border-border-input-primary-pressed bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
>
<StepIcon icon={Icon} className="text-border-input-primary-pressed" />
</div>
);
}
return (
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex size-[44px] shrink-0 items-center justify-center rounded-full border">
<div
className={`border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-center rounded-full border ${CIRCLE_SIZE}`}
>
<StepIcon icon={Icon} className="text-text-neutral-tertiary" />
</div>
);
}
function StepConnector({ isComplete }: { isComplete: boolean }) {
interface StepConnectorProps {
isComplete: boolean;
className: string;
direction: ConnectorDirection;
}
function StepConnector({
isComplete,
className,
direction,
}: StepConnectorProps) {
if (isComplete) {
return <div className="bg-border-input-primary-pressed h-14 w-px" />;
return <div className={cn("bg-border-input-primary-pressed", className)} />;
}
return (
<div
className="h-14 w-px"
className={className}
style={{
backgroundImage:
"repeating-linear-gradient(to bottom, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)",
backgroundImage: `repeating-linear-gradient(${direction}, var(--color-bg-data-muted) 0px, var(--color-bg-data-muted) 4px, transparent 4px, transparent 8px)`,
}}
/>
);
@@ -173,10 +226,17 @@ function StepIcon({
icon: IconComponent;
className: string;
}) {
// Decorative like lucide's; size classes beat the SVG's width/height attributes.
if (isCustomSvgIcon(Icon)) {
return <Icon size={24} className={className} />;
return (
<Icon
aria-hidden
size={24}
className={cn("size-4 lg:size-6", className)}
/>
);
}
return <Icon className={cn("size-6", className)} />;
return <Icon className={cn("size-4 lg:size-6", className)} />;
}
function isCustomSvgIcon(