feat(FN-1861): restore completed steps in onboarding resume flow
- Update getOnboardingResumeStep to include completedSteps array - Allow backward navigation to previously completed steps in onboarding modal - Show completed step count in the resume card UI - Restore completed steps when reopening the onboarding modal - Add comprehensive tests for completedSteps functionality and live auth reopen scenarios
This commit is contained in:
@@ -52,9 +52,12 @@ export function ModelOnboardingModal({
|
||||
const initialStep: OnboardingStep = persistedState && persistedState.currentStep !== "complete"
|
||||
? persistedState.currentStep as OnboardingStep
|
||||
: "ai-setup";
|
||||
// Restore completed steps from persisted state
|
||||
const persistedCompletedSteps = persistedState?.completedSteps ?? [];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [step, setStep] = useState<OnboardingStep>(initialStep);
|
||||
const [completedSteps, setCompletedSteps] = useState<OnboardingStep[]>(persistedCompletedSteps);
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [authActionInProgress, setAuthActionInProgress] = useState<string | null>(null);
|
||||
@@ -78,9 +81,9 @@ export function ModelOnboardingModal({
|
||||
// Persist step state whenever it changes (for resume functionality)
|
||||
useEffect(() => {
|
||||
if (step !== "complete") {
|
||||
saveOnboardingState(step);
|
||||
saveOnboardingState(step, { completedSteps });
|
||||
}
|
||||
}, [step]);
|
||||
}, [step, completedSteps]);
|
||||
|
||||
// Load auth providers
|
||||
const loadAuthStatus = useCallback(async () => {
|
||||
@@ -139,6 +142,8 @@ export function ModelOnboardingModal({
|
||||
|
||||
// Navigate to next step
|
||||
const handleNext = useCallback(() => {
|
||||
// Mark current step as completed before moving forward
|
||||
setCompletedSteps(prev => [...new Set([...prev, step])]);
|
||||
if (step === "ai-setup") {
|
||||
setStep("github");
|
||||
} else if (step === "github") {
|
||||
@@ -148,6 +153,9 @@ export function ModelOnboardingModal({
|
||||
|
||||
// Navigate to previous step
|
||||
const handleBack = useCallback(() => {
|
||||
// Remove current step from completedSteps when going back (undoing progress)
|
||||
const currentStepKey = step;
|
||||
setCompletedSteps(prev => prev.filter(s => s !== currentStepKey));
|
||||
if (step === "github") {
|
||||
setStep("ai-setup");
|
||||
} else if (step === "first-task") {
|
||||
@@ -476,31 +484,57 @@ export function ModelOnboardingModal({
|
||||
|
||||
{/* Step indicator - 3 progress steps + complete */}
|
||||
<div className="model-onboarding-steps">
|
||||
{steps.map((s, index) => (
|
||||
<div key={s.key} className="onboarding-step-wrapper">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={`model-onboarding-step-connector ${
|
||||
index <= currentStepIndex ? "done" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`model-onboarding-step-indicator ${
|
||||
step === s.key ? "active" : ""
|
||||
} ${currentStepIndex > index ? "done" : ""}`}
|
||||
>
|
||||
<span className="step-number">
|
||||
{currentStepIndex > index ? (
|
||||
<CheckCircle size={14} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<span className="step-label">{s.label}</span>
|
||||
{steps.map((s, index) => {
|
||||
// A step is done if it's in completedSteps AND is before current position
|
||||
const isDone = completedSteps.includes(s.key) && currentStepIndex > index;
|
||||
// Clickable if it's a completed step (can review) or we're on a future step
|
||||
const isClickable = isDone;
|
||||
return (
|
||||
<div key={s.key} className="onboarding-step-wrapper">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={`model-onboarding-step-connector ${
|
||||
index <= currentStepIndex ? "done" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{isClickable ? (
|
||||
<button
|
||||
className={`model-onboarding-step-indicator ${
|
||||
step === s.key ? "active" : ""
|
||||
} ${isDone ? "done" : ""}`}
|
||||
onClick={() => setStep(s.key)}
|
||||
aria-label={`Go back to ${s.label}`}
|
||||
title={`Review ${s.label}`}
|
||||
>
|
||||
<span className="step-number">
|
||||
{isDone ? (
|
||||
<CheckCircle size={14} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<span className="step-label">{s.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={`model-onboarding-step-indicator ${
|
||||
step === s.key ? "active" : ""
|
||||
} ${isDone ? "done" : ""}`}
|
||||
>
|
||||
<span className="step-number">
|
||||
{isDone ? (
|
||||
<CheckCircle size={14} />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<span className="step-label">{s.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -19,6 +19,12 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedCount = resumeStep.completedSteps.length;
|
||||
const totalSteps = 3; // ai-setup, github, first-task
|
||||
const progressText = completedCount > 0
|
||||
? `${completedCount} of ${totalSteps} step${completedCount !== 1 ? "s" : ""} complete — You're on the `
|
||||
: "You're on the ";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="onboarding-resume-card"
|
||||
@@ -32,7 +38,7 @@ export function OnboardingResumeCard({ onResume }: OnboardingResumeCardProps) {
|
||||
<div className="onboarding-resume-card__content">
|
||||
<h2 className="onboarding-resume-card__title">Continue Setup</h2>
|
||||
<p className="onboarding-resume-card__description">
|
||||
You're on the <strong>{resumeStep.label}</strong> step. Continue where you left off to complete your dashboard setup.
|
||||
{progressText}<strong>{resumeStep.label}</strong> step. Continue where you left off to complete your dashboard setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,6 +368,41 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking completed AI Setup step indicator navigates back without removing from completedSteps", async () => {
|
||||
// Start on GitHub step with AI Setup already completed
|
||||
mockGetOnboardingState.mockReturnValueOnce({
|
||||
currentStep: "github",
|
||||
completedSteps: ["ai-setup"],
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
// AI Setup step indicator should be clickable (shows as done)
|
||||
const aiSetupIndicator = screen.getByRole("button", { name: "Go back to AI Setup" });
|
||||
expect(aiSetupIndicator).toBeTruthy();
|
||||
|
||||
// Click the AI Setup step indicator
|
||||
fireEvent.click(aiSetupIndicator);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify saveOnboardingState was called with AI Setup still in completedSteps
|
||||
const saveCalls = mockSaveOnboardingState.mock.calls;
|
||||
const lastSaveCall = saveCalls[saveCalls.length - 1];
|
||||
expect(lastSaveCall[0]).toBe("ai-setup");
|
||||
expect(lastSaveCall[1]?.completedSteps).toContain("ai-setup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("First Task step", () => {
|
||||
@@ -712,6 +747,111 @@ describe("ModelOnboardingModal", () => {
|
||||
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||
expect(dropdown.value).toBe("");
|
||||
});
|
||||
|
||||
it("reopening with persisted github step loads auth status fresh and shows correct badges", async () => {
|
||||
// Mock persisted state showing user was on github step
|
||||
mockGetOnboardingState.mockReturnValueOnce({
|
||||
currentStep: "github",
|
||||
completedSteps: ["ai-setup"],
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
});
|
||||
|
||||
// Mock auth status with some authenticated providers
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
||||
{ id: "github", name: "GitHub", authenticated: true, type: "oauth" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify auth status was fetched fresh (not stale)
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalled();
|
||||
|
||||
// GitHub should show as connected
|
||||
expect(screen.getByTestId("onboarding-auth-status-github")).toBeTruthy();
|
||||
expect(screen.getByText("✓ Connected")).toBeTruthy();
|
||||
|
||||
// Should show Disconnect instead of Connect
|
||||
expect(screen.getByText("Disconnect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reopening with persisted selected model hydrates dropdown via loadGlobalSettings", async () => {
|
||||
// Mock global settings with a saved default model
|
||||
mockFetchGlobalSettings.mockResolvedValueOnce({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
modelOnboardingComplete: false,
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify global settings was fetched to hydrate dropdown
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalled();
|
||||
|
||||
// The model dropdown should be pre-populated with the saved default
|
||||
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||
expect(dropdown.value).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("reopening at github step with persisted model selection hydrates correctly", async () => {
|
||||
// Mock persisted state showing user was on first-task step
|
||||
mockGetOnboardingState.mockReturnValueOnce({
|
||||
currentStep: "first-task",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
});
|
||||
|
||||
// Mock global settings with a saved default model
|
||||
mockFetchGlobalSettings.mockResolvedValueOnce({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
modelOnboardingComplete: false,
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
// Wait for modal to show the first-task step
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Create Your First Task")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Verify global settings was fetched to hydrate any model selection state
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalled();
|
||||
|
||||
// Navigate back to see if the model dropdown has the saved value
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connect GitHub")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("← Back"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Up AI")).toBeTruthy();
|
||||
});
|
||||
|
||||
// The model dropdown should be pre-populated with the saved default
|
||||
const dropdown = screen.getByTestId("mock-model-dropdown") as HTMLSelectElement;
|
||||
expect(dropdown.value).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion state tracking", () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByRole("region", { name: "Resume onboarding" })).toBeInTheDocument();
|
||||
@@ -41,6 +42,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "github",
|
||||
label: "GitHub",
|
||||
completedSteps: ["ai-setup"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText("GitHub")).toBeInTheDocument();
|
||||
@@ -50,6 +52,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "first-task",
|
||||
label: "First Task",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText("Continue Setup")).toBeInTheDocument();
|
||||
@@ -59,6 +62,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText("Continue onboarding")).toBeInTheDocument();
|
||||
@@ -68,6 +72,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
const button = screen.getByRole("button", { name: "Continue onboarding" });
|
||||
@@ -80,6 +85,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
const onResume = vi.fn();
|
||||
render(<OnboardingResumeCard onResume={onResume} />);
|
||||
@@ -94,6 +100,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
const onResume = vi.fn();
|
||||
render(<OnboardingResumeCard onResume={onResume} />);
|
||||
@@ -111,6 +118,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText(/AI Setup/)).toBeInTheDocument();
|
||||
@@ -120,6 +128,7 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "github",
|
||||
label: "GitHub",
|
||||
completedSteps: ["ai-setup"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText(/GitHub/)).toBeInTheDocument();
|
||||
@@ -129,12 +138,49 @@ describe("OnboardingResumeCard", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "first-task",
|
||||
label: "First Task",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
expect(screen.getByText(/First Task/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completed step count", () => {
|
||||
it("shows progress text without count when 0 steps completed", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "ai-setup",
|
||||
label: "AI Setup",
|
||||
completedSteps: [],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// When 0 steps completed, text is just "You're on the <label> step"
|
||||
expect(screen.getByText(/^You're on the/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/AI Setup/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completed step count text with 1 completed step (singular)", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "github",
|
||||
label: "GitHub",
|
||||
completedSteps: ["ai-setup"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses singular "step" for 1 completed
|
||||
expect(screen.getByText(/1 of 3 step complete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows completed step count text with 2 completed steps (plural)", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue({
|
||||
currentStep: "first-task",
|
||||
label: "First Task",
|
||||
completedSteps: ["ai-setup", "github"],
|
||||
});
|
||||
render(<OnboardingResumeCard onResume={vi.fn()} />);
|
||||
// Uses plural "steps" for 2 completed
|
||||
expect(screen.getByText(/2 of 3 steps complete/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hidden state", () => {
|
||||
it("does not render when currentStep is null", () => {
|
||||
mockGetOnboardingResumeStep.mockReturnValue(null);
|
||||
|
||||
@@ -578,10 +578,39 @@ describe("model-onboarding-state", () => {
|
||||
expect(result).toEqual({
|
||||
currentStep: step,
|
||||
label: ONBOARDING_STEP_LABELS[step],
|
||||
completedSteps: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns completedSteps in the return value", () => {
|
||||
const state = {
|
||||
currentStep: "github" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
completedSteps: ["ai-setup"] as const,
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
};
|
||||
mockStore[STORAGE_KEY] = JSON.stringify(state);
|
||||
const result = getOnboardingResumeStep();
|
||||
expect(result?.completedSteps).toEqual(["ai-setup"]);
|
||||
});
|
||||
|
||||
it("returns empty completedSteps when field is missing (legacy state)", () => {
|
||||
const state = {
|
||||
currentStep: "github" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
dismissed: false,
|
||||
completed: false,
|
||||
stepData: {},
|
||||
// Note: no completedSteps field
|
||||
};
|
||||
mockStore[STORAGE_KEY] = JSON.stringify(state);
|
||||
const result = getOnboardingResumeStep();
|
||||
expect(result?.completedSteps).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns null when completed: true", () => {
|
||||
const state = {
|
||||
currentStep: "first-task" as const,
|
||||
@@ -623,6 +652,7 @@ describe("model-onboarding-state", () => {
|
||||
expect(result).toEqual({
|
||||
currentStep: "custom-step",
|
||||
label: "Custom Step", // Falls back to title-case formatting
|
||||
completedSteps: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ function applyStateDefaults(state: OnboardingState): OnboardingState {
|
||||
* Call this when the user dismisses the modal without completing.
|
||||
* @param step - The current step (known OnboardingStep or unknown string for future steps)
|
||||
* @param options - Optional rich payload for extended state tracking
|
||||
* @param options.completedSteps - Array of steps that have been completed (for resume functionality)
|
||||
* @param options.dismissed - Whether the user explicitly dismissed without finishing
|
||||
* @param options.completed - Whether the user finished all steps
|
||||
* @param options.stepData - Per-step data for restoring UI state on reopen
|
||||
*/
|
||||
export function saveOnboardingState(
|
||||
step: OnboardingStep | string,
|
||||
@@ -340,8 +344,12 @@ export function isOnboardingResumable(): boolean {
|
||||
/**
|
||||
* Get the step info needed to display the resume card.
|
||||
* Returns null if no resumable state exists (including if onboarding was completed).
|
||||
* @returns Object containing:
|
||||
* - currentStep: The step where the user left off
|
||||
* - label: Human-readable step label
|
||||
* - completedSteps: Array of steps the user has completed (for showing progress)
|
||||
*/
|
||||
export function getOnboardingResumeStep(): { currentStep: string; label: string } | null {
|
||||
export function getOnboardingResumeStep(): { currentStep: string; label: string; completedSteps: OnboardingStep[] } | null {
|
||||
const state = getOnboardingState();
|
||||
// Return null if no state, completed, or step is "complete"
|
||||
if (!state || isOnboardingCompleted() || state.currentStep === "complete") {
|
||||
@@ -355,6 +363,7 @@ export function getOnboardingResumeStep(): { currentStep: string; label: string
|
||||
return {
|
||||
currentStep: state.currentStep,
|
||||
label,
|
||||
completedSteps: state.completedSteps,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23187,6 +23187,19 @@ html .column.drag-over * {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.done {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.done:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator.done:focus-visible {
|
||||
outline: var(--focus-ring-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.model-onboarding-step-indicator .step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
Reference in New Issue
Block a user