diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx
index 9c53628cb..9a577d04d 100644
--- a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx
+++ b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx
@@ -71,8 +71,10 @@ export default function ServerLicenseModal({
slideKey={slide.key}
/>
-
-

+
+
+

+
diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts
index f9c08eb60..7243d79a2 100644
--- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts
+++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts
@@ -175,7 +175,7 @@ export const SLIDE_DEFINITIONS: Record = {
'server-license': {
id: 'server-license',
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
- hero: { type: 'logo' },
+ hero: { type: 'dual-icon' },
buttons: [
{
key: 'license-close',
diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx
index f64cf6dd2..babfb95c2 100644
--- a/frontend/src/core/components/shared/InfoBanner.tsx
+++ b/frontend/src/core/components/shared/InfoBanner.tsx
@@ -130,6 +130,11 @@ export const InfoBanner: React.FC = ({
onClick={onButtonClick}
loading={loading}
leftSection={}
+ styles={{
+ label: {
+ color: textColor ?? toneStyle.text,
+ },
+ }}
>
{buttonText}
diff --git a/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts
new file mode 100644
index 000000000..22ed22918
--- /dev/null
+++ b/frontend/src/desktop/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts
@@ -0,0 +1,390 @@
+/**
+ * Desktop override of useInitialOnboardingState.
+ *
+ * Key difference: Handles the simplified onboarding flow where desktop-install
+ * and security-check slides are removed. When welcome is the last slide,
+ * clicking Next will launch the tools tour instead of doing nothing.
+ */
+
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
+import { useOs } from '@app/hooks/useOs';
+import { useNavigate } from 'react-router-dom';
+import {
+ SLIDE_DEFINITIONS,
+ type ButtonAction,
+ type FlowState,
+ type SlideId,
+} from '@app/components/onboarding/onboardingFlowConfig';
+import type { LicenseNotice } from '@app/types/types';
+import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
+import { useServerExperience } from '@app/hooks/useServerExperience';
+import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
+import { DOWNLOAD_URLS } from '@app/constants/downloads';
+
+interface UseInitialOnboardingStateResult {
+ state: OnboardingState;
+ totalSteps: number;
+ slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
+ currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
+ licenseNotice: LicenseNotice;
+ flowState: FlowState;
+ closeAndMarkSeen: () => void;
+ handleButtonAction: (action: ButtonAction) => void;
+}
+
+export function useInitialOnboardingState({
+ opened,
+ onClose,
+ onRequestServerLicense,
+ onLicenseNoticeUpdate,
+}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
+ const { preferences, updatePreference } = usePreferences();
+ const { startTour } = useOnboarding();
+ const {
+ loginEnabled: loginEnabledFromServer,
+ configIsAdmin,
+ totalUsers: serverTotalUsers,
+ userCountResolved: serverUserCountResolved,
+ freeTierLimit,
+ hasPaidLicense,
+ scenarioKey,
+ setSelfReportedAdmin,
+ isNewServer,
+ } = useServerExperience();
+ const osType = useOs();
+ const navigate = useNavigate();
+ const selectedDownloadUrlRef = useRef('');
+
+ const [state, setState] = useState(DEFAULT_STATE);
+
+ const resetState = useCallback(() => {
+ setState(DEFAULT_STATE);
+ }, []);
+
+ useEffect(() => {
+ if (!opened) {
+ resetState();
+ }
+ }, [opened, resetState]);
+
+ const handleRoleSelect = useCallback(
+ (role: 'admin' | 'user' | null) => {
+ const isAdminSelection = role === 'admin';
+ setState((prev) => ({
+ ...prev,
+ selectedRole: role,
+ selfReportedAdmin: isAdminSelection,
+ }));
+
+ if (typeof window !== 'undefined') {
+ if (isAdminSelection) {
+ window.localStorage.setItem('stirling-self-reported-admin', 'true');
+ } else {
+ window.localStorage.removeItem('stirling-self-reported-admin');
+ }
+ }
+
+ setSelfReportedAdmin(isAdminSelection);
+ },
+ [setSelfReportedAdmin],
+ );
+
+ const closeAndMarkSeen = useCallback(() => {
+ if (!preferences.hasSeenIntroOnboarding) {
+ updatePreference('hasSeenIntroOnboarding', true);
+ }
+ onClose();
+ }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
+
+ const isAdmin = configIsAdmin;
+ const enableLogin = loginEnabledFromServer;
+
+ const effectiveEnableLogin = enableLogin;
+ const effectiveIsAdmin = isAdmin;
+ const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
+
+ useEffect(() => {
+ if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
+ handleRoleSelect('admin');
+ }
+ }, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
+
+ const shouldUseServerCount =
+ (effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
+ const licenseUserCountFromServer =
+ shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
+
+ const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
+
+ const os = useMemo(() => {
+ switch (osType) {
+ case 'windows':
+ return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
+ case 'mac-apple':
+ return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
+ case 'mac-intel':
+ return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
+ case 'linux-x64':
+ case 'linux-arm64':
+ return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
+ default:
+ return { label: '', url: '' };
+ }
+ }, [osType]);
+
+ const osOptions = useMemo(() => {
+ const options = [
+ { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
+ { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
+ { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
+ { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
+ ];
+ return options.filter(opt => opt.url);
+ }, []);
+
+ const resolvedFlow = useMemo(
+ () => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
+ [effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
+ );
+ // Desktop: No security-check slide, so no need to filter it out
+ const flowSlideIds = resolvedFlow.ids;
+ const flowType = resolvedFlow.type;
+ const totalSteps = flowSlideIds.length;
+ const maxIndex = Math.max(totalSteps - 1, 0);
+
+ useEffect(() => {
+ if (state.step >= flowSlideIds.length) {
+ setState((prev) => ({
+ ...prev,
+ step: Math.max(flowSlideIds.length - 1, 0),
+ }));
+ }
+ }, [flowSlideIds.length, state.step]);
+
+ const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
+ const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
+
+ if (!slideDefinition) {
+ return null;
+ }
+
+ const scenarioProvidesInfo =
+ scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
+ const scenarioIndicatesAdmin = scenarioProvidesInfo
+ ? scenarioKey!.includes('admin')
+ : state.selfReportedAdmin || effectiveIsAdmin;
+ const scenarioIndicatesOverLimit = scenarioProvidesInfo
+ ? scenarioKey!.includes('over-limit')
+ : effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
+ const scenarioRequiresLicense =
+ scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
+
+ const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
+
+ const licenseNotice = useMemo(
+ () => ({
+ totalUsers: effectiveLicenseUserCount,
+ freeTierLimit,
+ isOverLimit: scenarioIndicatesOverLimit,
+ requiresLicense: shouldShowServerLicenseInfo,
+ }),
+ [
+ effectiveLicenseUserCount,
+ freeTierLimit,
+ scenarioIndicatesOverLimit,
+ shouldShowServerLicenseInfo,
+ ],
+ );
+
+ const requestServerLicenseIfNeeded = useCallback(
+ (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
+ if (!shouldShowServerLicenseInfo) {
+ return;
+ }
+ onRequestServerLicense?.(options);
+ },
+ [onRequestServerLicense, shouldShowServerLicenseInfo],
+ );
+
+ useEffect(() => {
+ onLicenseNoticeUpdate?.(licenseNotice);
+ }, [licenseNotice, onLicenseNoticeUpdate]);
+
+ // Initialize ref with default URL
+ useEffect(() => {
+ if (!selectedDownloadUrlRef.current && os.url) {
+ selectedDownloadUrlRef.current = os.url;
+ }
+ }, [os.url]);
+
+ const handleDownloadUrlChange = useCallback((url: string) => {
+ selectedDownloadUrlRef.current = url;
+ }, []);
+
+ const currentSlide = slideDefinition.createSlide({
+ osLabel: os.label,
+ osUrl: os.url,
+ osOptions,
+ onDownloadUrlChange: handleDownloadUrlChange,
+ selectedRole: state.selectedRole,
+ onRoleSelect: handleRoleSelect,
+ licenseNotice,
+ loginEnabled: effectiveEnableLogin,
+ });
+
+ const goNext = useCallback(() => {
+ setState((prev) => ({
+ ...prev,
+ step: Math.min(prev.step + 1, maxIndex),
+ }));
+ }, [maxIndex]);
+
+ const goPrev = useCallback(() => {
+ setState((prev) => ({
+ ...prev,
+ step: Math.max(prev.step - 1, 0),
+ }));
+ }, []);
+
+ const launchTour = useCallback(
+ (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
+ if (options?.closeOnboardingSlides) {
+ closeAndMarkSeen();
+ }
+
+ startTour(mode, {
+ source: 'initial-onboarding-modal',
+ metadata: {
+ hasCompletedOnboarding: preferences.hasCompletedOnboarding,
+ toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
+ selfReportedAdmin: state.selfReportedAdmin,
+ },
+ });
+ },
+ [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
+ );
+
+ const handleButtonAction = useCallback(
+ (action: ButtonAction) => {
+ const isOnLastSlide = state.step >= maxIndex;
+
+ // Desktop: For login-user and no-login flows, when on the last slide (welcome),
+ // clicking Next should launch the tools tour
+ const shouldAutoLaunchToolsTour =
+ (flowType === 'login-user' || flowType === 'no-login') && isOnLastSlide;
+
+ switch (action) {
+ case 'next':
+ if (shouldAutoLaunchToolsTour) {
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ }
+ goNext();
+ return;
+ case 'prev':
+ goPrev();
+ return;
+ case 'close':
+ closeAndMarkSeen();
+ return;
+ case 'download-selected': {
+ const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
+ if (downloadUrl) {
+ window.open(downloadUrl, '_blank', 'noopener');
+ }
+ if (shouldAutoLaunchToolsTour) {
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ }
+ goNext();
+ return;
+ }
+ case 'complete-close':
+ updatePreference('hasCompletedOnboarding', true);
+ closeAndMarkSeen();
+ return;
+ case 'security-next':
+ // Desktop: security-check slide is removed, but keep this for completeness
+ if (!state.selectedRole) {
+ return;
+ }
+ if (state.selectedRole === 'admin') {
+ goNext();
+ } else {
+ launchTour('tools', { closeOnboardingSlides: true });
+ }
+ return;
+ case 'launch-admin':
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: true,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ launchTour('admin', { closeOnboardingSlides: true });
+ return;
+ case 'launch-tools':
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ case 'launch-auto': {
+ const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
+ if (launchMode === 'admin') {
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: true,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ }
+ launchTour(launchMode, { closeOnboardingSlides: true });
+ return;
+ }
+ case 'skip-to-license':
+ updatePreference('hasCompletedOnboarding', true);
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: false,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ closeAndMarkSeen();
+ return;
+ case 'see-plans':
+ closeAndMarkSeen();
+ navigate('/settings/adminPlan');
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ closeAndMarkSeen,
+ currentSlide,
+ currentSlideId,
+ effectiveIsAdmin,
+ flowType,
+ goNext,
+ goPrev,
+ launchTour,
+ maxIndex,
+ navigate,
+ requestServerLicenseIfNeeded,
+ onRequestServerLicense,
+ os.url,
+ state.selectedRole,
+ state.selfReportedAdmin,
+ state.step,
+ updatePreference,
+ ],
+ );
+
+ const flowState: FlowState = { selectedRole: state.selectedRole };
+
+ return {
+ state,
+ totalSteps,
+ slideDefinition,
+ currentSlide,
+ licenseNotice,
+ flowState,
+ closeAndMarkSeen,
+ handleButtonAction,
+ };
+}
+
diff --git a/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts b/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts
new file mode 100644
index 000000000..6eaf245a1
--- /dev/null
+++ b/frontend/src/desktop/components/onboarding/onboardingFlowConfig.ts
@@ -0,0 +1,178 @@
+/**
+ * Desktop override of onboarding flow config.
+ *
+ * This version removes the desktop-install and security-check slides
+ * since they're not relevant when running as a desktop app.
+ *
+ * The SlideId type still includes all values for type compatibility,
+ * but the actual FLOW_SEQUENCES don't use these slides.
+ */
+
+import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
+import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
+import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
+import { SlideConfig, LicenseNotice } from '@app/types/types';
+
+// Keep the full type for compatibility, but these slides won't be used
+export type SlideId =
+ | 'welcome'
+ | 'desktop-install'
+ | 'security-check'
+ | 'admin-overview'
+ | 'server-license';
+
+export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
+
+export type ButtonAction =
+ | 'next'
+ | 'prev'
+ | 'close'
+ | 'complete-close'
+ | 'download-selected'
+ | 'security-next'
+ | 'launch-admin'
+ | 'launch-tools'
+ | 'launch-auto'
+ | 'see-plans'
+ | 'skip-to-license';
+
+export interface FlowState {
+ selectedRole: 'admin' | 'user' | null;
+}
+
+export interface OSOption {
+ label: string;
+ url: string;
+ value: string;
+}
+
+export interface SlideFactoryParams {
+ osLabel: string;
+ osUrl: string;
+ osOptions?: OSOption[];
+ onDownloadUrlChange?: (url: string) => void;
+ selectedRole: 'admin' | 'user' | null;
+ onRoleSelect: (role: 'admin' | 'user' | null) => void;
+ licenseNotice?: LicenseNotice;
+ loginEnabled?: boolean;
+}
+
+export interface HeroDefinition {
+ type: HeroType;
+}
+
+export interface ButtonDefinition {
+ key: string;
+ type: 'button' | 'icon';
+ label?: string;
+ icon?: 'chevron-left';
+ variant?: 'primary' | 'secondary' | 'default';
+ group: 'left' | 'right';
+ action: ButtonAction;
+ disabledWhen?: (state: FlowState) => boolean;
+}
+
+export interface SlideDefinition {
+ id: SlideId;
+ createSlide: (params: SlideFactoryParams) => SlideConfig;
+ hero: HeroDefinition;
+ buttons: ButtonDefinition[];
+}
+
+export const SLIDE_DEFINITIONS: Record = {
+ 'welcome': {
+ id: 'welcome',
+ createSlide: () => WelcomeSlide(),
+ hero: { type: 'rocket' },
+ buttons: [
+ {
+ key: 'welcome-next',
+ type: 'button',
+ label: 'onboarding.buttons.next',
+ variant: 'primary',
+ group: 'right',
+ action: 'next',
+ },
+ ],
+ },
+ // Stub definitions for desktop-install and security-check - not used on desktop
+ // but kept for type compatibility with core code
+ 'desktop-install': {
+ id: 'desktop-install',
+ createSlide: () => WelcomeSlide(), // Placeholder - never used
+ hero: { type: 'dual-icon' },
+ buttons: [],
+ },
+ 'security-check': {
+ id: 'security-check',
+ createSlide: () => WelcomeSlide(), // Placeholder - never used
+ hero: { type: 'shield' },
+ buttons: [],
+ },
+ 'admin-overview': {
+ id: 'admin-overview',
+ createSlide: ({ licenseNotice, loginEnabled }) =>
+ PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
+ hero: { type: 'diamond' },
+ buttons: [
+ {
+ key: 'admin-back',
+ type: 'icon',
+ icon: 'chevron-left',
+ group: 'left',
+ action: 'prev',
+ },
+ {
+ key: 'admin-show',
+ type: 'button',
+ label: 'onboarding.buttons.showMeAround',
+ variant: 'primary',
+ group: 'right',
+ action: 'launch-admin',
+ },
+ {
+ key: 'admin-skip',
+ type: 'button',
+ label: 'onboarding.buttons.skipTheTour',
+ variant: 'secondary',
+ group: 'left',
+ action: 'skip-to-license',
+ },
+ ],
+ },
+ 'server-license': {
+ id: 'server-license',
+ createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
+ hero: { type: 'dual-icon' },
+ buttons: [
+ {
+ key: 'license-close',
+ type: 'button',
+ label: 'onboarding.buttons.skipForNow',
+ variant: 'secondary',
+ group: 'left',
+ action: 'close',
+ },
+ {
+ key: 'license-see-plans',
+ type: 'button',
+ label: 'onboarding.serverLicense.seePlans',
+ variant: 'primary',
+ group: 'right',
+ action: 'see-plans',
+ },
+ ],
+ },
+};
+
+/**
+ * Desktop flow sequences - simplified without desktop-install and security-check slides
+ * since users are already on desktop and security check is not needed.
+ */
+export const FLOW_SEQUENCES = {
+ loginAdmin: ['welcome', 'admin-overview'] as SlideId[],
+ loginUser: ['welcome'] as SlideId[],
+ noLoginBase: ['welcome'] as SlideId[],
+ noLoginAdmin: ['admin-overview'] as SlideId[],
+};
+