From d9acb41d2f35ca86b072f878780f64aed7193fbc Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Wed, 18 Feb 2026 16:45:12 +0100 Subject: [PATCH] feat(launcher): add ProjectCreationWizard multi-step flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces CreateProjectModal with a guided wizard supporting: - Quick Start (name + location, direct creation) - Guided Setup (basics → style preset → review → create) - AI Builder stub (V2, same flow as Guided for now) Files added: - WizardContext.tsx — state machine with step sequencing + canProceed validation - EntryModeStep — card-based mode selector - ProjectBasicsStep — name/description/location picker - StylePresetStep — preset card grid - ReviewStep — summary before creation - ProjectCreationWizard.tsx — drop-in replacement for CreateProjectModal - SCSS + index.ts barrel Integration: - ProjectsPage.tsx imports ProjectCreationWizard (API-identical swap) Tests: - tests/models/ProjectCreationWizard.test.ts — 17 pure logic tests (infra note: npm run test:editor required, not npx jest directly) Pre-existing broken tests in tests/components/ and tests/git/ are unrelated and not modified. --- .../ProjectCreationWizard.module.scss | 74 ++++++ .../ProjectCreationWizard.tsx | 183 +++++++++++++++ .../ProjectCreationWizard/WizardContext.tsx | 145 ++++++++++++ .../components/ProjectCreationWizard/index.ts | 3 + .../steps/EntryModeStep.module.scss | 72 ++++++ .../steps/EntryModeStep.tsx | 69 ++++++ .../steps/ProjectBasicsStep.module.scss | 32 +++ .../steps/ProjectBasicsStep.tsx | 90 ++++++++ .../steps/ReviewStep.module.scss | 86 +++++++ .../steps/ReviewStep.tsx | 82 +++++++ .../steps/StylePresetStep.module.scss | 11 + .../steps/StylePresetStep.tsx | 33 +++ .../src/pages/ProjectsPage/ProjectsPage.tsx | 4 +- .../models/ProjectCreationWizard.test.ts | 215 ++++++++++++++++++ 14 files changed, 1097 insertions(+), 2 deletions(-) create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/WizardContext.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/index.ts create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.tsx create mode 100644 packages/noodl-editor/tests/models/ProjectCreationWizard.test.ts diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.module.scss new file mode 100644 index 0000000..f0fefd7 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.module.scss @@ -0,0 +1,74 @@ +/* ProjectCreationWizard — modal shell styles */ + +.Backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.55); + z-index: 1000; +} + +.Modal { + background-color: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-border-default); + border-radius: var(--radius-lg); + min-width: 520px; + max-width: 640px; + width: 100%; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35); + z-index: 1001; + display: flex; + flex-direction: column; + max-height: 90vh; + overflow: hidden; +} + +/* ---- Header ---- */ + +.Header { + padding: var(--spacing-5) var(--spacing-6); + border-bottom: 1px solid var(--theme-color-border-default); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.Title { + font-size: 18px; + font-weight: 600; + color: var(--theme-color-fg-default); + margin: 0; + line-height: 1.3; +} + +.StepLabel { + font-size: 12px; + color: var(--theme-color-fg-default-shy); + font-weight: 500; + letter-spacing: 0.03em; +} + +/* ---- Content ---- */ + +.Content { + padding: var(--spacing-6); + overflow-y: auto; + flex: 1; +} + +/* ---- Footer ---- */ + +.Footer { + padding: var(--spacing-4) var(--spacing-6); + border-top: 1px solid var(--theme-color-border-default); + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.tsx new file mode 100644 index 0000000..5f9d953 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/ProjectCreationWizard.tsx @@ -0,0 +1,183 @@ +/** + * ProjectCreationWizard - Multi-step project creation flow + * + * Replaces CreateProjectModal with a guided experience that supports: + * - Quick Start (name + location → create) + * - Guided Setup (name/description → style preset → review → create) + * - AI Builder stub (coming in V2) + * + * The onConfirm signature is identical to CreateProjectModal so ProjectsPage + * requires only an import-name swap. + * + * @module noodl-core-ui/preview/launcher + */ +import React from 'react'; + +import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets'; + +import css from './ProjectCreationWizard.module.scss'; +import { EntryModeStep } from './steps/EntryModeStep'; +import { ProjectBasicsStep } from './steps/ProjectBasicsStep'; +import { ReviewStep } from './steps/ReviewStep'; +import { StylePresetStep } from './steps/StylePresetStep'; +import { WizardProvider, useWizardContext, DEFAULT_PRESET_ID, WizardStep } from './WizardContext'; + +// ----- Public API ----------------------------------------------------------- + +export interface ProjectCreationWizardProps { + isVisible: boolean; + onClose: () => void; + /** + * Called when the user confirms project creation. + * Signature is identical to the legacy CreateProjectModal.onConfirm so + * callers need no changes beyond swapping the import. + */ + onConfirm: (name: string, location: string, presetId: string) => void; + /** Open a native folder picker; returns the chosen path or null if cancelled */ + onChooseLocation?: () => Promise; + /** Style presets to show in the preset picker step */ + presets?: PresetDisplayInfo[]; +} + +// ----- Step metadata -------------------------------------------------------- + +const STEP_TITLES: Record = { + entry: 'Create New Project', + basics: 'Project Basics', + preset: 'Style Preset', + review: 'Review' +}; + +/** Steps where the Back button should be hidden (entry has no "back") */ +const STEPS_WITHOUT_BACK: WizardStep[] = ['entry']; + +// ----- Inner wizard (has access to context) --------------------------------- + +interface WizardInnerProps extends Omit { + presets: PresetDisplayInfo[]; +} + +function WizardInner({ onClose, onConfirm, onChooseLocation, presets }: WizardInnerProps) { + const { state, goNext, goBack, canProceed } = useWizardContext(); + + const { currentStep, mode, projectName, location, selectedPresetId } = state; + + // Determine if this is the final step before creation + const isLastStep = currentStep === 'review' || (mode === 'quick' && currentStep === 'basics'); + + const nextLabel = isLastStep ? 'Create Project' : 'Next'; + const showBack = !STEPS_WITHOUT_BACK.includes(currentStep); + + const handleNext = () => { + if (isLastStep) { + // Fire creation with the wizard state values + onConfirm(projectName.trim(), location, selectedPresetId); + } else { + goNext(); + } + }; + + // Render the active step body + const renderStep = () => { + switch (currentStep) { + case 'entry': + return ; + case 'basics': + return Promise.resolve(null))} />; + case 'preset': + return ; + case 'review': + return ; + } + }; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{STEP_TITLES[currentStep]}

+ + {/* Step indicator (not shown on entry screen) */} + {currentStep !== 'entry' && ( + {mode === 'quick' ? 'Quick Start' : 'Guided Setup'} + )} +
+ + {/* Content */} +
{renderStep()}
+ + {/* Footer — hidden on entry (entry step uses card clicks to advance) */} + {currentStep !== 'entry' && ( +
+ {showBack && ( + + )} + + + + +
+ )} +
+
+ ); +} + +// ----- Public component (manages provider lifecycle) ------------------------ + +/** + * ProjectCreationWizard — Drop-in replacement for CreateProjectModal. + * + * @example + * // ProjectsPage.tsx — only change the import, nothing else + * import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard'; + * + * + */ +export function ProjectCreationWizard({ + isVisible, + onClose, + onConfirm, + onChooseLocation, + presets +}: ProjectCreationWizardProps) { + if (!isVisible) return null; + + // Key the provider on `isVisible` so state fully resets each time the + // modal opens — no stale name/location from the previous session. + return ( + + + + ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/WizardContext.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/WizardContext.tsx new file mode 100644 index 0000000..56b51dd --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/WizardContext.tsx @@ -0,0 +1,145 @@ +/** + * WizardContext - Shared state for the Project Creation Wizard + * + * Manages wizard step flow, form values, and mode selection. + * Passed via React context so all step components can read/write without prop drilling. + */ +import React, { createContext, useCallback, useContext, useState } from 'react'; + +// ----- Types ---------------------------------------------------------------- + +/** The entry-mode choice the user makes on the first screen */ +export type WizardMode = 'quick' | 'guided' | 'ai'; + +/** + * Step identifiers in the guided flow. + * Quick mode only visits 'basics' (no preset or review step). + */ +export type WizardStep = 'entry' | 'basics' | 'preset' | 'review'; + +export const DEFAULT_PRESET_ID = 'modern'; + +export interface WizardState { + /** Active wizard mode chosen by the user */ + mode: WizardMode; + /** Current step being displayed */ + currentStep: WizardStep; + /** Project name entered by the user */ + projectName: string; + /** Optional project description (guided mode only) */ + description: string; + /** Folder path chosen via native dialog */ + location: string; + /** ID of the selected style preset */ + selectedPresetId: string; +} + +export interface WizardContextValue { + state: WizardState; + /** Update one or more fields of the wizard state */ + update: (partial: Partial) => void; + /** Move forward to the next logical step (mode-aware) */ + goNext: () => void; + /** Move back to the previous step */ + goBack: () => void; + /** Whether the current step has all required data to proceed */ + canProceed: boolean; +} + +// ----- Context -------------------------------------------------------------- + +const WizardContext = createContext(null); + +export function useWizardContext(): WizardContextValue { + const ctx = useContext(WizardContext); + if (!ctx) throw new Error('useWizardContext must be used within WizardProvider'); + return ctx; +} + +// ----- Step ordering -------------------------------------------------------- + +/** Returns the ordered list of steps for the given mode */ +function getStepSequence(mode: WizardMode): WizardStep[] { + switch (mode) { + case 'quick': + // Quick Start: just fill in name/location, no preset picker or review + return ['basics']; + case 'guided': + return ['basics', 'preset', 'review']; + case 'ai': + // AI mode is a stub for V1 — same as guided until AI is wired + return ['basics', 'preset', 'review']; + } +} + +// ----- Validation ----------------------------------------------------------- + +function isStepValid(step: WizardStep, state: WizardState): boolean { + switch (step) { + case 'entry': + // Entry screen has no data — user just picks a mode + return true; + case 'basics': + return state.projectName.trim().length > 0 && state.location.length > 0; + case 'preset': + return state.selectedPresetId.length > 0; + case 'review': + return true; + } +} + +// ----- Provider ------------------------------------------------------------- + +export interface WizardProviderProps { + children: React.ReactNode; + initialState?: Partial; +} + +export function WizardProvider({ children, initialState }: WizardProviderProps) { + const [state, setStateRaw] = useState({ + mode: 'quick', + currentStep: 'entry', + projectName: '', + description: '', + location: '', + selectedPresetId: DEFAULT_PRESET_ID, + ...initialState + }); + + const update = useCallback((partial: Partial) => { + setStateRaw((prev) => ({ ...prev, ...partial })); + }, []); + + const goNext = useCallback(() => { + setStateRaw((prev) => { + if (prev.currentStep === 'entry') { + // Entry → first step of the chosen mode sequence + const seq = getStepSequence(prev.mode); + return { ...prev, currentStep: seq[0] }; + } + const seq = getStepSequence(prev.mode); + const idx = seq.indexOf(prev.currentStep); + if (idx === -1 || idx >= seq.length - 1) return prev; + return { ...prev, currentStep: seq[idx + 1] }; + }); + }, []); + + const goBack = useCallback(() => { + setStateRaw((prev) => { + if (prev.currentStep === 'entry') return prev; + const seq = getStepSequence(prev.mode); + const idx = seq.indexOf(prev.currentStep); + if (idx <= 0) { + // Back from the first real step → return to entry screen + return { ...prev, currentStep: 'entry' }; + } + return { ...prev, currentStep: seq[idx - 1] }; + }); + }, []); + + const canProceed = isStepValid(state.currentStep, state); + + return ( + {children} + ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/index.ts b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/index.ts new file mode 100644 index 0000000..2cc6b49 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/index.ts @@ -0,0 +1,3 @@ +export { ProjectCreationWizard } from './ProjectCreationWizard'; +export type { ProjectCreationWizardProps } from './ProjectCreationWizard'; +export type { WizardMode, WizardStep, WizardState } from './WizardContext'; diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.module.scss new file mode 100644 index 0000000..60560e2 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.module.scss @@ -0,0 +1,72 @@ +.EntryModeStep { + display: flex; + flex-direction: column; +} + +.EntryModeStep-prompt { + font-size: 15px; + color: var(--theme-color-fg-default-shy); + margin: 0 0 var(--spacing-5) 0; +} + +.EntryModeStep-cards { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.ModeCard { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-1); + padding: var(--spacing-4) var(--spacing-5); + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: var(--radius-default); + cursor: pointer; + text-align: left; + transition: border-color 0.15s ease, background-color 0.15s ease; + position: relative; + + &:hover:not(&--disabled) { + border-color: var(--theme-color-primary, #ef4444); + background-color: var(--theme-color-bg-2); + } + + &:focus-visible { + outline: 2px solid var(--theme-color-primary, #ef4444); + outline-offset: 2px; + } + + &--disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.ModeCard-title { + font-size: 14px; + font-weight: 600; + color: var(--theme-color-fg-default); + line-height: 1.3; +} + +.ModeCard-description { + font-size: 13px; + color: var(--theme-color-fg-default-shy); + line-height: 1.5; +} + +.ModeCard-badge { + position: absolute; + top: var(--spacing-2); + right: var(--spacing-3); + font-size: 11px; + font-weight: 500; + color: var(--theme-color-fg-default-shy); + background-color: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-border-default); + border-radius: var(--radius-sm, 4px); + padding: 2px var(--spacing-2); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.tsx new file mode 100644 index 0000000..9aab6ce --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/EntryModeStep.tsx @@ -0,0 +1,69 @@ +/** + * EntryModeStep - First screen of the Project Creation Wizard + * + * Lets the user choose between Quick Start, Guided Setup, or AI Builder (stub). + */ +import React from 'react'; + +import { WizardMode, useWizardContext } from '../WizardContext'; +import css from './EntryModeStep.module.scss'; + +interface ModeCardProps { + mode: WizardMode; + title: string; + description: string; + isDisabled?: boolean; + onSelect: (mode: WizardMode) => void; +} + +function ModeCard({ mode, title, description, isDisabled, onSelect }: ModeCardProps) { + return ( + + ); +} + +export function EntryModeStep() { + const { update, goNext } = useWizardContext(); + + const handleSelect = (mode: WizardMode) => { + update({ mode }); + goNext(); + }; + + return ( +
+

How would you like to start?

+ +
+ + + +
+
+ ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.module.scss new file mode 100644 index 0000000..cadad6a --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.module.scss @@ -0,0 +1,32 @@ +.ProjectBasicsStep { + display: flex; + flex-direction: column; +} + +.Field { + margin-bottom: var(--spacing-5); + + &:last-child { + margin-bottom: 0; + } +} + +.LocationRow { + display: flex; + align-items: center; + margin-top: var(--spacing-2); +} + +.PathPreview { + margin-top: var(--spacing-4); + padding: var(--spacing-3); + background-color: var(--theme-color-bg-3); + border-radius: var(--radius-default); + border: 1px solid var(--theme-color-border-default); +} + +.PathText { + font-size: 13px; + color: var(--theme-color-fg-default-shy); + line-height: 1.4; +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.tsx new file mode 100644 index 0000000..2a2d9a9 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ProjectBasicsStep.tsx @@ -0,0 +1,90 @@ +/** + * ProjectBasicsStep - Name, optional description, and folder location + * + * Shown in both Quick Start and Guided modes. + * Description field is only shown in Guided mode. + */ +import React, { useCallback } from 'react'; + +import { PrimaryButton, PrimaryButtonVariant, PrimaryButtonSize } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { TextInput } from '@noodl-core-ui/components/inputs/TextInput'; +import { Label } from '@noodl-core-ui/components/typography/Label'; + +import { useWizardContext } from '../WizardContext'; +import css from './ProjectBasicsStep.module.scss'; + +export interface ProjectBasicsStepProps { + /** Called when the user clicks "Browse..." to pick a folder */ + onChooseLocation: () => Promise; +} + +export function ProjectBasicsStep({ onChooseLocation }: ProjectBasicsStepProps) { + const { state, update } = useWizardContext(); + const isGuided = state.mode === 'guided' || state.mode === 'ai'; + + const handleChooseLocation = useCallback(async () => { + const chosen = await onChooseLocation(); + if (chosen) { + update({ location: chosen }); + } + }, [onChooseLocation, update]); + + return ( +
+ {/* Project Name */} +
+ + update({ projectName: e.target.value })} + placeholder="My New Project" + isAutoFocus + UNSAFE_style={{ marginTop: 'var(--spacing-2)' }} + /> +
+ + {/* Description — guided mode only */} + {isGuided && ( +
+ + update({ description: e.target.value })} + placeholder="A brief description of your project..." + UNSAFE_style={{ marginTop: 'var(--spacing-2)' }} + /> +
+ )} + + {/* Location */} +
+ +
+ update({ location: e.target.value })} + placeholder="Choose folder..." + isReadonly + UNSAFE_style={{ flex: 1 }} + /> + +
+
+ + {/* Path preview */} + {state.projectName && state.location && ( +
+ + Full path: {state.location}/{state.projectName}/ + +
+ )} +
+ ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.module.scss new file mode 100644 index 0000000..ffd76bd --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.module.scss @@ -0,0 +1,86 @@ +.ReviewStep { + display: flex; + flex-direction: column; +} + +.ReviewStep-subtitle { + font-size: 13px; + color: var(--theme-color-fg-default-shy); + margin: 0 0 var(--spacing-4) 0; +} + +.Summary { + display: flex; + flex-direction: column; + border: 1px solid var(--theme-color-border-default); + border-radius: var(--radius-default); + overflow: hidden; +} + +.SummaryRow { + display: grid; + grid-template-columns: 80px 1fr auto; + align-items: start; + gap: var(--spacing-3); + padding: var(--spacing-4) var(--spacing-5); + border-bottom: 1px solid var(--theme-color-border-default); + + &:last-child { + border-bottom: none; + } +} + +.SummaryRow-label { + font-size: 12px; + font-weight: 600; + color: var(--theme-color-fg-default-shy); + text-transform: uppercase; + letter-spacing: 0.04em; + padding-top: 2px; +} + +.SummaryRow-value { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.SummaryRow-main { + font-size: 14px; + color: var(--theme-color-fg-default); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.SummaryRow-secondary { + font-size: 12px; + color: var(--theme-color-fg-default-shy); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.SummaryRow-edit { + font-size: 12px; + color: var(--theme-color-primary, #ef4444); + background: none; + border: none; + cursor: pointer; + padding: 0; + line-height: 1.6; + flex-shrink: 0; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--theme-color-primary, #ef4444); + outline-offset: 2px; + border-radius: 2px; + } +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.tsx new file mode 100644 index 0000000..1c129f3 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/ReviewStep.tsx @@ -0,0 +1,82 @@ +/** + * ReviewStep - Final summary before project creation + * + * Shows the chosen settings and lets the user go back to edit any step. + */ +import React from 'react'; + +import { PresetDisplayInfo } from '@noodl-core-ui/components/StylePresets'; + +import { useWizardContext } from '../WizardContext'; +import css from './ReviewStep.module.scss'; + +export interface ReviewStepProps { + presets: PresetDisplayInfo[]; +} + +export function ReviewStep({ presets }: ReviewStepProps) { + const { state, update, goBack } = useWizardContext(); + + const selectedPreset = presets.find((p) => p.id === state.selectedPresetId); + + const handleEditBasics = () => { + // Navigate back to basics by jumping steps + update({ currentStep: 'basics' }); + }; + + const handleEditPreset = () => { + update({ currentStep: 'preset' }); + }; + + return ( +
+

Review your settings before creating.

+ +
+ {/* Basics row */} +
+
Project
+
+ {state.projectName || '—'} + {state.description && {state.description}} +
+ +
+ + {/* Location row */} +
+
Location
+
+ + {state.location || '—'} + + {state.projectName && state.location && ( + + Full path: {state.location}/{state.projectName}/ + + )} +
+ +
+ + {/* Style preset row */} +
+
Style
+
+ {selectedPreset?.name ?? state.selectedPresetId} + {selectedPreset?.description && ( + {selectedPreset.description} + )} +
+ +
+
+
+ ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.module.scss new file mode 100644 index 0000000..9473c6c --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.module.scss @@ -0,0 +1,11 @@ +.StylePresetStep { + display: flex; + flex-direction: column; +} + +.StylePresetStep-hint { + font-size: 13px; + color: var(--theme-color-fg-default-shy); + margin: 0 0 var(--spacing-4) 0; + line-height: 1.5; +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.tsx new file mode 100644 index 0000000..1ea27e8 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/ProjectCreationWizard/steps/StylePresetStep.tsx @@ -0,0 +1,33 @@ +/** + * StylePresetStep - Style preset selection for guided mode + * + * Reuses the existing PresetSelector component built in STYLE-003. + */ +import React from 'react'; + +import { PresetDisplayInfo, PresetSelector } from '@noodl-core-ui/components/StylePresets'; + +import { useWizardContext } from '../WizardContext'; +import css from './StylePresetStep.module.scss'; + +export interface StylePresetStepProps { + /** Preset data passed in from the editor (avoid circular dep) */ + presets: PresetDisplayInfo[]; +} + +export function StylePresetStep({ presets }: StylePresetStepProps) { + const { state, update } = useWizardContext(); + + return ( +
+

+ Choose a visual style for your project. You can customise colors and fonts later. +

+ update({ selectedPresetId: id })} + /> +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx b/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx index adbc063..6016ffa 100644 --- a/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx +++ b/packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx @@ -10,11 +10,11 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { clone } from '@noodl/git/src/core/clone'; import { filesystem } from '@noodl/platform'; -import { CreateProjectModal } from '@noodl-core-ui/preview/launcher/Launcher/components/CreateProjectModal'; import { CloudSyncType, LauncherProjectData } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard'; +import { ProjectCreationWizard } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectCreationWizard'; import { useGitHubRepos, NoodlGitHubRepo, @@ -942,7 +942,7 @@ export function ProjectsPage(props: ProjectsPageProps) { onCloneRepo={handleCloneRepo} /> - 0 && state.location.length > 0; + case 'preset': + return state.selectedPresetId.length > 0; + case 'review': + return true; + default: + return false; + } +} + +// ---- Step navigation (mirrors WizardContext goNext/goBack logic) ----------- + +function goNext(state) { + if (state.currentStep === 'entry') { + const seq = getStepSequence(state.mode); + return seq[0]; + } + const seq = getStepSequence(state.mode); + const idx = seq.indexOf(state.currentStep); + if (idx === -1 || idx >= seq.length - 1) return state.currentStep; + return seq[idx + 1]; +} + +function goBack(state) { + if (state.currentStep === 'entry') return 'entry'; + const seq = getStepSequence(state.mode); + const idx = seq.indexOf(state.currentStep); + if (idx <= 0) return 'entry'; + return seq[idx - 1]; +} + +// ---- Whether the current step is the last one before creation -------------- + +function isLastStep(mode, step) { + return step === 'review' || (mode === 'quick' && step === 'basics'); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('WizardContext: step sequences', () => { + it('quick mode only visits basics', () => { + expect(getStepSequence('quick')).toEqual(['basics']); + }); + + it('guided mode visits basics, preset, review', () => { + expect(getStepSequence('guided')).toEqual(['basics', 'preset', 'review']); + }); + + it('ai mode uses same sequence as guided (V1 stub)', () => { + expect(getStepSequence('ai')).toEqual(['basics', 'preset', 'review']); + }); +}); + +describe('WizardContext: validation', () => { + const baseState = { + mode: 'quick', + currentStep: 'basics', + projectName: '', + description: '', + location: '', + selectedPresetId: 'modern' + }; + + it('entry step is always valid', () => { + expect(isStepValid('entry', { ...baseState, currentStep: 'entry' })).toBe(true); + }); + + it('review step is always valid', () => { + expect(isStepValid('review', { ...baseState, currentStep: 'review' })).toBe(true); + }); + + it('basics step requires projectName and location', () => { + expect(isStepValid('basics', baseState)).toBe(false); + }); + + it('basics step passes with name and location', () => { + expect(isStepValid('basics', { ...baseState, projectName: 'My Project', location: '/tmp' })).toBe(true); + }); + + it('basics step trims whitespace on projectName', () => { + expect(isStepValid('basics', { ...baseState, projectName: ' ', location: '/tmp' })).toBe(false); + }); + + it('preset step requires selectedPresetId', () => { + expect(isStepValid('preset', { ...baseState, selectedPresetId: '' })).toBe(false); + }); + + it('preset step passes with a preset id', () => { + expect(isStepValid('preset', { ...baseState, selectedPresetId: 'minimal' })).toBe(true); + }); +}); + +describe('WizardContext: goNext navigation', () => { + const baseState = { + mode: 'quick', + currentStep: 'entry', + projectName: 'Test', + description: '', + location: '/tmp', + selectedPresetId: 'modern' + }; + + it('quick: entry advances to basics', () => { + expect(goNext({ ...baseState, mode: 'quick', currentStep: 'entry' })).toBe('basics'); + }); + + it('quick: basics stays (is the last step)', () => { + expect(goNext({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('basics'); + }); + + it('guided: entry advances to basics', () => { + expect(goNext({ ...baseState, mode: 'guided', currentStep: 'entry' })).toBe('basics'); + }); + + it('guided: basics advances to preset', () => { + expect(goNext({ ...baseState, mode: 'guided', currentStep: 'basics' })).toBe('preset'); + }); + + it('guided: preset advances to review', () => { + expect(goNext({ ...baseState, mode: 'guided', currentStep: 'preset' })).toBe('review'); + }); + + it('guided: review stays (is the last step)', () => { + expect(goNext({ ...baseState, mode: 'guided', currentStep: 'review' })).toBe('review'); + }); +}); + +describe('WizardContext: goBack navigation', () => { + const baseState = { + mode: 'guided', + currentStep: 'review', + projectName: 'Test', + description: '', + location: '/tmp', + selectedPresetId: 'modern' + }; + + it('entry stays on entry when going back', () => { + expect(goBack({ ...baseState, currentStep: 'entry' })).toBe('entry'); + }); + + it('guided: basics goes back to entry', () => { + expect(goBack({ ...baseState, currentStep: 'basics' })).toBe('entry'); + }); + + it('guided: preset goes back to basics', () => { + expect(goBack({ ...baseState, currentStep: 'preset' })).toBe('basics'); + }); + + it('guided: review goes back to preset', () => { + expect(goBack({ ...baseState, currentStep: 'review' })).toBe('preset'); + }); + + it('quick: basics goes back to entry', () => { + expect(goBack({ ...baseState, mode: 'quick', currentStep: 'basics' })).toBe('entry'); + }); +}); + +describe('isLastStep: determines when to show Create Project button', () => { + it('quick mode: basics is the last step', () => { + expect(isLastStep('quick', 'basics')).toBe(true); + }); + + it('quick mode: entry is not the last step', () => { + expect(isLastStep('quick', 'entry')).toBe(false); + }); + + it('guided mode: review is the last step', () => { + expect(isLastStep('guided', 'review')).toBe(true); + }); + + it('guided mode: basics is not the last step', () => { + expect(isLastStep('guided', 'basics')).toBe(false); + }); + + it('guided mode: preset is not the last step', () => { + expect(isLastStep('guided', 'preset')).toBe(false); + }); +});