Update onboarding to V1 browser and desktop flows (#6714)

* Remove unused `telemetryLoader`

* Remove onboarding redirect behavior

* Allow subRoute to be passed to navigateToProject

* Replace warning dialog routes with toasts

* Wire up new utilities and toasts to UI components

* Add home sidebar buttons for tutorial flow

* Rename menu item

* Add flex-1 so home-layout fills available space

* Remove onboarding avatar tests, they are becoming irrelevant

* Consolidate onboarding tests to one longer one

and update it to not use pixel color checks, and use fixtures.

* Shorten warning toast button text

* tsc, lint, and circular deps

* Update circular dep file

* Fix mistakes made in circular update tweaking

* One more dumb created circular dep

* Update src/routes/Onboarding/utils.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Fix narrow screen home layout breaking

* fix: kevin, navigation routes fixed

* fix: filename parsing is correct now for onboarding with the last file sep

* Fix e2e test state checks that are diff on Linux

* Create onboarding project entirely through systemIOMachine

* Fix Windows path construction

* Make utility to verify a string is an onboarding value

* Little biome formatting suggestion fix

* Units onboarding step was not using OnboardingButtons

* Add type checking of next and previous status, fix useNextClick

* Update `OnboardingStatus` type on WASM side

* Make onboarding different on browser and web, placeholder component

* Show proof of concept with custom content per route

* Make text type args not insta dismiss when you click anywhere

* Make some utility hooks for the onboarding

* Update requestedProjectName along with requestedProjectName

* Build out a rough draft of desktop onboarding

* Remove unused onboarding route files

* Build out rough draft of browser onboarding content

* @jgomez720 browser flow feedback

* @jgomez420 desktop feedback

* tsc and lints

* Tweaks

* Import is dead, long live Add files

* What's up with my inability to type "highlight"?

* Codespell and String casting

* Update browser sample to be axial fan

* lint and tsc

* codespell again

* Remove unused nightmare function `useDemoCode`

* Add a few unit tests

* Update desktop to use bulk file creation from #6747

* Oops overwrote main.kcl on the modify with text-to-cad step

* Undo the dumb use of `sep` that I introduced

* Fix up project test

which was fragile to the number of steps in the onboarding smh

* Fix up onboarding flow test

* typo

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: Kevin Nadro <kevin@zoo.dev>
This commit is contained in:
Frank Noirot
2025-05-08 20:37:21 -04:00
committed by GitHub
parent 9853353512
commit 3b7b4f85a1
42 changed files with 2366 additions and 1164 deletions

View File

@ -21,9 +21,8 @@ test.describe('Onboarding tests', () => {
}, },
}) })
const bracketComment = '// Shelf Bracket'
const tutorialWelcomeHeading = page.getByText( const tutorialWelcomeHeading = page.getByText(
'Welcome to Design Studio! This' 'Welcome to Zoo Design Studio'
) )
const nextButton = page.getByTestId('onboarding-next') const nextButton = page.getByTestId('onboarding-next')
const prevButton = page.getByTestId('onboarding-prev') const prevButton = page.getByTestId('onboarding-prev')
@ -90,9 +89,8 @@ test.describe('Onboarding tests', () => {
// }) // })
await test.step('Ensure we see the welcome screen in a new project', async () => { await test.step('Ensure we see the welcome screen in a new project', async () => {
await expect(toolbar.projectName).toContainText('Tutorial Project 00') await expect(toolbar.projectName).toContainText('tutorial-project')
await expect(tutorialWelcomeHeading).toBeVisible() await expect(tutorialWelcomeHeading).toBeVisible()
await editor.expectEditor.toContain(bracketComment)
await scene.connectionEstablished() await scene.connectionEstablished()
await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 }) await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
}) })
@ -122,7 +120,7 @@ test.describe('Onboarding tests', () => {
}) })
}) })
await test.step('Resetting onboarding from inside project should always make a new one', async () => { await test.step('Resetting onboarding from inside project should always overwrite `tutorial-project`', async () => {
await test.step('Reset onboarding from settings', async () => { await test.step('Reset onboarding from settings', async () => {
await userMenuButton.click() await userMenuButton.click()
await userMenuSettingsButton.click() await userMenuSettingsButton.click()
@ -131,45 +129,70 @@ test.describe('Onboarding tests', () => {
await restartOnboardingSettingsButton.click() await restartOnboardingSettingsButton.click()
}) })
await test.step('Makes a new project', async () => { await test.step('Gets to the onboarding start', async () => {
await expect(toolbar.projectName).toContainText('Tutorial Project 01') await expect(toolbar.projectName).toContainText('tutorial-project')
await expect(tutorialWelcomeHeading).toBeVisible() await expect(tutorialWelcomeHeading).toBeVisible()
await editor.expectEditor.toContain(bracketComment)
await scene.connectionEstablished() await scene.connectionEstablished()
await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 }) await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
}) })
await test.step('Dismiss the onboarding', async () => { await test.step('Dismiss the onboarding', async () => {
await postDismissToast.waitFor({ state: 'detached' }) await postDismissToast.waitFor({ state: 'hidden' })
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(postDismissToast).toBeVisible() await expect(postDismissToast).toBeVisible()
await expect(page.getByTestId('onboarding-content')).not.toBeVisible() await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
await expect.poll(() => page.url()).not.toContain('/onboarding') await expect.poll(() => page.url()).not.toContain('/onboarding')
}) })
})
await test.step('Resetting onboarding from home help menu makes a new project', async () => { await test.step('Verify no new projects were created', async () => {
await test.step('Go home and reset onboarding from lower-right help menu', async () => {
await toolbar.logoLink.click() await toolbar.logoLink.click()
await expect(homePage.tutorialBtn).not.toBeVisible() await expect(homePage.tutorialBtn).not.toBeVisible()
await expect( await homePage.expectState({
homePage.projectCard.getByText('Tutorial Project 00') projectCards: [
).toBeVisible() { title: 'tutorial-project', fileCount: 7 },
await expect( {
homePage.projectCard.getByText('Tutorial Project 01') title: 'testDefault',
).toBeVisible() fileCount: 1,
},
await helpMenuButton.click() ],
await helpMenuRestartOnboardingButton.click() sortBy: 'last-modified-desc',
})
}) })
})
await test.step('Makes a new project', async () => { await test.step('Resetting onboarding from home help menu overwrites the `tutorial-project`', async () => {
await expect(toolbar.projectName).toContainText('Tutorial Project 02') await helpMenuButton.click()
await helpMenuRestartOnboardingButton.click()
await test.step('Gets to the onboarding start', async () => {
await expect(toolbar.projectName).toContainText('tutorial-project')
await expect(tutorialWelcomeHeading).toBeVisible() await expect(tutorialWelcomeHeading).toBeVisible()
await editor.expectEditor.toContain(bracketComment)
await scene.connectionEstablished() await scene.connectionEstablished()
await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 }) await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 15_000 })
}) })
await test.step('Dismiss the onboarding', async () => {
await postDismissToast.waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
await expect(postDismissToast).toBeVisible()
await expect(page.getByTestId('onboarding-content')).not.toBeVisible()
await expect.poll(() => page.url()).not.toContain('/onboarding')
})
await test.step('Verify no new projects were created', async () => {
await toolbar.logoLink.click()
await expect(homePage.tutorialBtn).not.toBeVisible()
await homePage.expectState({
projectCards: [
{ title: 'tutorial-project', fileCount: 7 },
{
title: 'testDefault',
fileCount: 1,
},
],
sortBy: 'last-modified-desc',
})
})
}) })
}) })
}) })

View File

@ -1986,6 +1986,7 @@ test(
'Original project name persist after onboarding', 'Original project name persist after onboarding',
{ tag: '@electron' }, { tag: '@electron' },
async ({ page, toolbar }, testInfo) => { async ({ page, toolbar }, testInfo) => {
const nextButton = page.getByTestId('onboarding-next')
await page.setBodyDimensions({ width: 1200, height: 500 }) await page.setBodyDimensions({ width: 1200, height: 500 })
const getAllProjects = () => page.getByTestId('project-link').all() const getAllProjects = () => page.getByTestId('project-link').all()
@ -2000,10 +2001,10 @@ test(
await page.getByTestId('user-settings').click() await page.getByTestId('user-settings').click()
await page.getByRole('button', { name: 'Replay Onboarding' }).click() await page.getByRole('button', { name: 'Replay Onboarding' }).click()
const numberOfOnboardingSteps = 12 while ((await nextButton.innerText()) !== 'Finish') {
for (let clicks = 0; clicks < numberOfOnboardingSteps; clicks++) { await nextButton.click()
await page.getByTestId('onboarding-next').click()
} }
await nextButton.click()
await page.getByTestId('project-sidebar-toggle').click() await page.getByTestId('project-sidebar-toggle').click()
}) })
@ -2013,7 +2014,7 @@ test(
}) })
await test.step('Should show the original project called wrist brace', async () => { await test.step('Should show the original project called wrist brace', async () => {
const projectNames = ['Tutorial Project 00', 'wrist brace'] const projectNames = ['tutorial-project', 'wrist brace']
for (const [index, projectLink] of (await getAllProjects()).entries()) { for (const [index, projectLink] of (await getAllProjects()).entries()) {
await expect(projectLink).toContainText(projectNames[index]) await expect(projectLink).toContainText(projectNames[index])
} }

View File

@ -1,7 +1,6 @@
import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes' import type { SaveSettingsPayload } from '@src/lib/settings/settingsTypes'
import { Themes } from '@src/lib/theme' import { Themes } from '@src/lib/theme'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import type { Settings } from '@rust/kcl-lib/bindings/Settings' import type { Settings } from '@rust/kcl-lib/bindings/Settings'
@ -29,28 +28,6 @@ export const TEST_SETTINGS: DeepPartial<Settings> = {
}, },
} }
export const TEST_SETTINGS_ONBOARDING_USER_MENU: DeepPartial<Settings> = {
...TEST_SETTINGS,
app: {
...TEST_SETTINGS.app,
onboarding_status: ONBOARDING_SUBPATHS.USER_MENU,
},
}
export const TEST_SETTINGS_ONBOARDING_EXPORT: DeepPartial<Settings> = {
...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, onboarding_status: ONBOARDING_SUBPATHS.EXPORT },
}
export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING: DeepPartial<Settings> =
{
...TEST_SETTINGS,
app: {
...TEST_SETTINGS.app,
onboarding_status: ONBOARDING_SUBPATHS.PARAMETRIC_MODELING,
},
}
export const TEST_SETTINGS_ONBOARDING_START: DeepPartial<Settings> = { export const TEST_SETTINGS_ONBOARDING_START: DeepPartial<Settings> = {
...TEST_SETTINGS, ...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, onboarding_status: '' }, app: { ...TEST_SETTINGS.app, onboarding_status: '' },

View File

@ -496,43 +496,84 @@ pub enum OnboardingStatus {
/// The user has dismissed onboarding. /// The user has dismissed onboarding.
Dismissed, Dismissed,
// Routes // Desktop Routes
#[serde(rename = "/")] #[serde(rename = "/desktop")]
#[display("/")] #[display("/desktop")]
Index, DesktopWelcome,
#[serde(rename = "/camera")] #[serde(rename = "/desktop/scene")]
#[display("/camera")] #[display("/desktop/scene")]
Camera, DesktopScene,
#[serde(rename = "/streaming")] #[serde(rename = "/desktop/toolbar")]
#[display("/streaming")] #[display("/desktop/toolbar")]
Streaming, DesktopToolbar,
#[serde(rename = "/editor")] #[serde(rename = "/desktop/text-to-cad")]
#[display("/editor")] #[display("/desktop/text-to-cad")]
Editor, DesktopTextToCadWelcome,
#[serde(rename = "/parametric-modeling")] #[serde(rename = "/desktop/text-to-cad-prompt")]
#[display("/parametric-modeling")] #[display("/desktop/text-to-cad-prompt")]
ParametricModeling, DesktopTextToCadPrompt,
#[serde(rename = "/interactive-numbers")] #[serde(rename = "/desktop/feature-tree-pane")]
#[display("/interactive-numbers")] #[display("/desktop/feature-tree-pane")]
InteractiveNumbers, DesktopFeatureTreePane,
#[serde(rename = "/command-k")] #[serde(rename = "/desktop/code-pane")]
#[display("/command-k")] #[display("/desktop/code-pane")]
CommandK, DesktopCodePane,
#[serde(rename = "/user-menu")] #[serde(rename = "/desktop/project-pane")]
#[display("/user-menu")] #[display("/desktop/project-pane")]
UserMenu, DesktopProjectFilesPane,
#[serde(rename = "/project-menu")] #[serde(rename = "/desktop/other-panes")]
#[display("/project-menu")] #[display("/desktop/other-panes")]
ProjectMenu, DesktopOtherPanes,
#[serde(rename = "/export")] #[serde(rename = "/desktop/prompt-to-edit")]
#[display("/export")] #[display("/desktop/prompt-to-edit")]
Export, DesktopPromptToEditWelcome,
#[serde(rename = "/sketching")] #[serde(rename = "/desktop/prompt-to-edit-prompt")]
#[display("/sketching")] #[display("/desktop/prompt-to-edit-prompt")]
Sketching, DesktopPromptToEditPrompt,
#[serde(rename = "/future-work")] #[serde(rename = "/desktop/prompt-to-edit-result")]
#[display("/future-work")] #[display("/desktop/prompt-to-edit-result")]
FutureWork, DesktopPromptToEditResult,
#[serde(rename = "/desktop/imports")]
#[display("/desktop/imports")]
DesktopImports,
#[serde(rename = "/desktop/exports")]
#[display("/desktop/exports")]
DesktopExports,
#[serde(rename = "/desktop/conclusion")]
#[display("/desktop/conclusion")]
DesktopConclusion,
// Browser Routes
#[serde(rename = "/browser")]
#[display("/browser")]
BrowserWelcome,
#[serde(rename = "/browser/scene")]
#[display("/browser/scene")]
BrowserScene,
#[serde(rename = "/browser/toolbar")]
#[display("/browser/toolbar")]
BrowserToolbar,
#[serde(rename = "/browser/text-to-cad")]
#[display("/browser/text-to-cad")]
BrowserTextToCadWelcome,
#[serde(rename = "/browser/text-to-cad-prompt")]
#[display("/browser/text-to-cad-prompt")]
BrowserTextToCadPrompt,
#[serde(rename = "/browser/feature-tree-pane")]
#[display("/browser/feature-tree-pane")]
BrowserFeatureTreePane,
#[serde(rename = "/browser/prompt-to-edit")]
#[display("/browser/prompt-to-edit")]
BrowserPromptToEditWelcome,
#[serde(rename = "/browser/prompt-to-edit-prompt")]
#[display("/browser/prompt-to-edit-prompt")]
BrowserPromptToEditPrompt,
#[serde(rename = "/browser/prompt-to-edit-result")]
#[display("/browser/prompt-to-edit-result")]
BrowserPromptToEditResult,
#[serde(rename = "/browser/conclusion")]
#[display("/browser/conclusion")]
BrowserConclusion,
} }
fn is_default<T: Default + PartialEq>(t: &T) -> bool { fn is_default<T: Default + PartialEq>(t: &T) -> bool {

View File

@ -42,7 +42,6 @@ import {
ONBOARDING_TOAST_ID, ONBOARDING_TOAST_ID,
TutorialRequestToast, TutorialRequestToast,
} from '@src/routes/Onboarding/utils' } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
// CYCLIC REF // CYCLIC REF
sceneInfra.camControls.engineStreamActor = engineStreamActor sceneInfra.camControls.engineStreamActor = engineStreamActor
@ -91,10 +90,6 @@ export function App() {
const settings = useSettings() const settings = useSettings()
const authToken = useToken() const authToken = useToken()
const {
app: { onboardingStatus },
} = settings
useHotkeys('backspace', (e) => { useHotkeys('backspace', (e) => {
e.preventDefault() e.preventDefault()
}) })
@ -110,13 +105,6 @@ export function App() {
toast.success('Your work is auto-saved in real-time') toast.success('Your work is auto-saved in real-time')
}) })
const paneOpacity = [
ONBOARDING_SUBPATHS.CAMERA,
ONBOARDING_SUBPATHS.STREAMING,
].some((p) => p === onboardingStatus.current)
? 'opacity-20'
: ''
useEngineConnectionSubscriptions() useEngineConnectionSubscriptions()
useEffect(() => { useEffect(() => {
@ -160,7 +148,7 @@ export function App() {
return ( return (
<div className="relative h-full flex flex-col" ref={ref}> <div className="relative h-full flex flex-col" ref={ref}>
<AppHeader <AppHeader
className={`transition-opacity transition-duration-75 ${paneOpacity}`} className="transition-opacity transition-duration-75"
project={{ project, file }} project={{ project, file }}
enableMenu={true} enableMenu={true}
> >
@ -168,7 +156,7 @@ export function App() {
<ShareButton /> <ShareButton />
</AppHeader> </AppHeader>
<ModalContainer /> <ModalContainer />
<ModelingSidebar paneOpacity={paneOpacity} /> <ModelingSidebar />
<EngineStream pool={pool} authToken={authToken} /> <EngineStream pool={pool} authToken={authToken} />
{/* <CamToggle /> */} {/* <CamToggle /> */}
<LowerRightControls navigate={navigate}> <LowerRightControls navigate={navigate}>

View File

@ -38,7 +38,7 @@ import { reportRejection } from '@src/lib/trap'
import { useToken } from '@src/lib/singletons' import { useToken } from '@src/lib/singletons'
import RootLayout from '@src/Root' import RootLayout from '@src/Root'
import Home from '@src/routes/Home' import Home from '@src/routes/Home'
import Onboarding, { onboardingRoutes } from '@src/routes/Onboarding' import { OnboardingRootRoute, onboardingRoutes } from '@src/routes/Onboarding'
import { Settings } from '@src/routes/Settings' import { Settings } from '@src/routes/Settings'
import SignIn from '@src/routes/SignIn' import SignIn from '@src/routes/SignIn'
import { Telemetry } from '@src/routes/Telemetry' import { Telemetry } from '@src/routes/Telemetry'
@ -102,8 +102,8 @@ const router = createRouter([
element: <Settings />, element: <Settings />,
}, },
{ {
path: makeUrlPathRelative(PATHS.ONBOARDING.INDEX), path: makeUrlPathRelative(PATHS.ONBOARDING),
element: <Onboarding />, element: <OnboardingRootRoute />,
children: onboardingRoutes, children: onboardingRoutes,
}, },
], ],

View File

@ -195,6 +195,7 @@ export function Toolbar({
return ( return (
<menu <menu
data-current-mode={currentMode} data-current-mode={currentMode}
data-onboarding-id="toolbar"
className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm" className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm"
> >
<ul <ul
@ -231,6 +232,7 @@ export function Toolbar({
Element="button" Element="button"
key={selectedIcon.id} key={selectedIcon.id}
data-testid={selectedIcon.id + '-dropdown'} data-testid={selectedIcon.id + '-dropdown'}
data-onboarding-id={selectedIcon.id + '-dropdown'}
id={selectedIcon.id + '-dropdown'} id={selectedIcon.id + '-dropdown'}
name={maybeIconConfig.id} name={maybeIconConfig.id}
className={ className={
@ -265,6 +267,7 @@ export function Toolbar({
Element="button" Element="button"
id={selectedIcon.id} id={selectedIcon.id}
data-testid={selectedIcon.id} data-testid={selectedIcon.id}
data-onboarding-id={selectedIcon.id}
iconStart={{ iconStart={{
icon: selectedIcon.icon, icon: selectedIcon.icon,
iconColor: selectedIcon.iconColor, iconColor: selectedIcon.iconColor,
@ -331,6 +334,7 @@ export function Toolbar({
key={itemConfig.id} key={itemConfig.id}
id={itemConfig.id} id={itemConfig.id}
data-testid={itemConfig.id} data-testid={itemConfig.id}
data-onboarding-id={itemConfig.id}
iconStart={{ iconStart={{
icon: itemConfig.icon, icon: itemConfig.icon,
iconColor: itemConfig.iconColor, iconColor: itemConfig.iconColor,

View File

@ -26,7 +26,10 @@ export function ActionButtonDropdown({
}: ActionButtonSplitProps) { }: ActionButtonSplitProps) {
const baseClassNames = `action-button p-0 m-0 group mono text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10` const baseClassNames = `action-button p-0 m-0 group mono text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10`
return ( return (
<Popover className={`${baseClassNames} ${className}`}> <Popover
className={`${baseClassNames} ${className}`}
data-onboarding-id={`${props.name}-group`}
>
{({ close }) => ( {({ close }) => (
<> <>
{children} {children}
@ -37,6 +40,7 @@ export function ActionButtonDropdown({
'enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 ' + 'enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 ' +
'pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10 p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary' 'pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10 p-0 m-0 rounded-none !outline-none ui-open:border-primary ui-open:bg-primary'
} }
data-onboarding-id={`${props.name}-dropdown-button`}
> >
<CustomIcon <CustomIcon
name="caretDown" name="caretDown"
@ -72,6 +76,7 @@ export function ActionButtonDropdown({
tabIndex={-1} tabIndex={-1}
disabled={item.disabled} disabled={item.disabled}
data-testid={'dropdown-' + item.id} data-testid={'dropdown-' + item.id}
data-onboarding-id={`${props.name}-dropdown-item`}
> >
<span className="capitalize flex-grow text-left"> <span className="capitalize flex-grow text-left">
{item.label} {item.label}

View File

@ -23,10 +23,13 @@ export const CommandBar = () => {
const { const {
context: { selectedCommand, currentArgument, commands }, context: { selectedCommand, currentArgument, commands },
} = commandBarState } = commandBarState
const isSelectionArgument = const isArgumentThatShouldBeHardToDismiss =
currentArgument?.inputType === 'selection' || currentArgument?.inputType === 'selection' ||
currentArgument?.inputType === 'selectionMixed' currentArgument?.inputType === 'selectionMixed' ||
const WrapperComponent = isSelectionArgument ? Popover : Dialog currentArgument?.inputType === 'text'
const WrapperComponent = isArgumentThatShouldBeHardToDismiss
? Popover
: Dialog
// Close the command bar when navigating // Close the command bar when navigating
useEffect(() => { useEffect(() => {
@ -120,13 +123,16 @@ export const CommandBar = () => {
as={Fragment} as={Fragment}
> >
<WrapperComponent <WrapperComponent
open={!commandBarState.matches('Closed') || isSelectionArgument} open={
!commandBarState.matches('Closed') ||
isArgumentThatShouldBeHardToDismiss
}
onClose={() => { onClose={() => {
commandBarActor.send({ type: 'Close' }) commandBarActor.send({ type: 'Close' })
}} }}
className={ className={
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' + 'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
(isSelectionArgument ? 'pointer-events-none' : '') (isArgumentThatShouldBeHardToDismiss ? 'pointer-events-none' : '')
} }
data-testid="command-bar-wrapper" data-testid="command-bar-wrapper"
> >

View File

@ -13,7 +13,7 @@ import {
acceptOnboarding, acceptOnboarding,
catchOnboardingWarnError, catchOnboardingWarnError,
} from '@src/routes/Onboarding/utils' } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { onboardingStartPath } from '@src/lib/onboardingPaths'
const HelpMenuDivider = () => ( const HelpMenuDivider = () => (
<div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" /> <div className="h-[1px] bg-chalkboard-110 dark:bg-chalkboard-80" />
@ -29,7 +29,7 @@ export function HelpMenu({
const resetOnboardingWorkflow = () => { const resetOnboardingWorkflow = () => {
const props = { const props = {
onboardingStatus: ONBOARDING_SUBPATHS.INDEX, onboardingStatus: onboardingStartPath,
navigate, navigate,
codeManager, codeManager,
kclManager, kclManager,

View File

@ -5,8 +5,6 @@ import { ActionButton } from '@src/components/ActionButton'
import { ActionIcon } from '@src/components/ActionIcon' import { ActionIcon } from '@src/components/ActionIcon'
import type { CustomIconName } from '@src/components/CustomIcon' import type { CustomIconName } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { useSettings } from '@src/lib/singletons'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import styles from './ModelingPane.module.css' import styles from './ModelingPane.module.css'
@ -68,12 +66,6 @@ export const ModelingPane = ({
title, title,
...props ...props
}: ModelingPaneProps) => { }: ModelingPaneProps) => {
const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus
const pointerEventsCssClass =
onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA
? 'pointer-events-none '
: 'pointer-events-auto '
return ( return (
<section <section
{...props} {...props}
@ -82,7 +74,6 @@ export const ModelingPane = ({
id={id} id={id}
className={ className={
'focus-within:border-primary dark:focus-within:border-chalkboard-50 ' + 'focus-within:border-primary dark:focus-within:border-chalkboard-50 ' +
pointerEventsCssClass +
styles.panel + styles.panel +
' group ' + ' group ' +
(className || '') (className || '')

View File

@ -24,17 +24,12 @@ import { SIDEBAR_BUTTON_SUFFIX } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { useSettings } from '@src/lib/singletons' import { useSettings } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons' import { commandBarActor } from '@src/lib/singletons'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { reportRejection } from '@src/lib/trap' import { reportRejection } from '@src/lib/trap'
import { refreshPage } from '@src/lib/utils' import { refreshPage } from '@src/lib/utils'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper' import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import usePlatform from '@src/hooks/usePlatform' import usePlatform from '@src/hooks/usePlatform'
import { settingsActor } from '@src/lib/singletons' import { settingsActor } from '@src/lib/singletons'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
}
interface BadgeInfoComputed { interface BadgeInfoComputed {
value: number | boolean | string value: number | boolean | string
onClick?: MouseEventHandler<any> onClick?: MouseEventHandler<any>
@ -46,14 +41,12 @@ function getPlatformString(): 'web' | 'desktop' {
return isDesktop() ? 'desktop' : 'web' return isDesktop() ? 'desktop' : 'web'
} }
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { export function ModelingSidebar() {
const machineManager = useContext(MachineManagerContext) const machineManager = useContext(MachineManagerContext)
const kclContext = useKclContext() const kclContext = useKclContext()
const settings = useSettings() const settings = useSettings()
const onboardingStatus = settings.app.onboardingStatus
const { send, context } = useModelingContext() const { send, context } = useModelingContext()
const pointerEventsCssClass = const pointerEventsCssClass =
onboardingStatus.current === ONBOARDING_SUBPATHS.CAMERA ||
context.store?.openPanes.length === 0 context.store?.openPanes.length === 0
? 'pointer-events-none ' ? 'pointer-events-none '
: 'pointer-events-auto ' : 'pointer-events-auto '
@ -225,7 +218,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
return ( return (
<Resizable <Resizable
className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`} className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${pointerEventsCssClass}`}
defaultSize={{ defaultSize={{
width: '550px', width: '550px',
height: 'auto', height: 'auto',
@ -361,7 +354,11 @@ function ModelingPaneButton({
}) })
return ( return (
<div id={paneConfig.id + '-button-holder'} className="relative"> <div
id={paneConfig.id + '-button-holder'}
className="relative"
data-onboarding-id={`${paneConfig.id}-pane-button`}
>
<button <button
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary" className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick} onClick={onClick}

View File

@ -83,12 +83,12 @@ function AppLogoLink({
to={PATHS.HOME} to={PATHS.HOME}
className={wrapperClassName + ' hover:before:brightness-110'} className={wrapperClassName + ' hover:before:brightness-110'}
> >
<Logo className={logoClassName} /> <Logo data-onboarding-id="app-logo" className={logoClassName} />
<span className="sr-only">{APP_NAME}</span> <span className="sr-only">{APP_NAME}</span>
</Link> </Link>
) : ( ) : (
<div className={wrapperClassName} data-testid="app-logo"> <div className={wrapperClassName} data-testid="app-logo">
<Logo className={logoClassName} /> <Logo data-onboarding-id="app-logo" className={logoClassName} />
<span className="sr-only">{APP_NAME}</span> <span className="sr-only">{APP_NAME}</span>
</div> </div>
) )

View File

@ -52,7 +52,8 @@ export function SystemIOMachineLogicListenerDesktop() {
) )
const requestedPath = joinRouterPaths( const requestedPath = joinRouterPaths(
PATHS.FILE, PATHS.FILE,
safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile) safeEncodeForRouterPaths(projectPathWithoutSpecificKCLFile),
requestedProjectName.subRoute || ''
) )
navigate(requestedPath) navigate(requestedPath)
}, [requestedProjectName]) }, [requestedProjectName])
@ -156,7 +157,10 @@ export function SystemIOMachineLogicListenerDesktop() {
settings: { highlightEdges: settings.modeling.highlightEdges.current }, settings: { highlightEdges: settings.modeling.highlightEdges.current },
}) })
.then(() => { .then(() => {
billingActor.send({ type: BillingTransition.Update, apiToken: token }) billingActor.send({
type: BillingTransition.Update,
apiToken: token,
})
}) })
.catch(reportRejection) .catch(reportRejection)
}, [requestedTextToCadGeneration]) }, [requestedTextToCadGeneration])

View File

@ -11,7 +11,7 @@ import { SettingsSection } from '@src/components/Settings/SettingsSection'
import { getSettingsFolderPaths } from '@src/lib/desktopFS' import { getSettingsFolderPaths } from '@src/lib/desktopFS'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { onboardingStartPath } from '@src/lib/onboardingPaths'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
import type { Setting } from '@src/lib/settings/initialSettings' import type { Setting } from '@src/lib/settings/initialSettings'
import type { import type {
@ -69,7 +69,7 @@ export const AllSettingsFields = forwardRef(
async function restartOnboarding() { async function restartOnboarding() {
const props = { const props = {
onboardingStatus: ONBOARDING_SUBPATHS.INDEX, onboardingStatus: onboardingStartPath,
navigate, navigate,
codeManager, codeManager,
kclManager, kclManager,

View File

@ -198,6 +198,7 @@ code {
#code-mirror-override .cm-content { #code-mirror-override .cm-content {
@apply caret-primary; @apply caret-primary;
} }
.dark #code-mirror-override .cm-content { .dark #code-mirror-override .cm-content {
@apply caret-chalkboard-10; @apply caret-chalkboard-10;
} }
@ -216,6 +217,7 @@ code {
100% { 100% {
opacity: 0; opacity: 0;
} }
10% { 10% {
opacity: 1; opacity: 1;
} }
@ -258,9 +260,11 @@ code {
#code-mirror-override .cm-tooltip-autocomplete li { #code-mirror-override .cm-tooltip-autocomplete li {
@apply px-2 py-1; @apply px-2 py-1;
} }
#code-mirror-override .cm-tooltip-autocomplete li[aria-selected="true"] { #code-mirror-override .cm-tooltip-autocomplete li[aria-selected="true"] {
@apply bg-liquid-10 text-liquid-110; @apply bg-liquid-10 text-liquid-110;
} }
.dark #code-mirror-override .cm-tooltip-autocomplete li[aria-selected="true"] { .dark #code-mirror-override .cm-tooltip-autocomplete li[aria-selected="true"] {
@apply bg-liquid-100 text-liquid-20; @apply bg-liquid-100 text-liquid-20;
} }
@ -339,6 +343,22 @@ code {
.outline-appForeground { .outline-appForeground {
@apply outline-chalkboard-100 dark:outline-chalkboard-10; @apply outline-chalkboard-100 dark:outline-chalkboard-10;
} }
/* highlight an object with a moving dashed outline */
.onboarding-highlight {
@apply outline outline-2;
animation: onboarding-highlight 0.7s ease-in-out infinite alternate-reverse;
}
@keyframes onboarding-highlight {
0% {
outline-offset: 0px;
}
100% {
outline-offset: 4px;
}
}
} }
#code-mirror-override .cm-scroller, #code-mirror-override .cm-scroller,

View File

@ -36,7 +36,7 @@ export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
/** The default name given to new kcl files in a project */ /** The default name given to new kcl files in a project */
export const DEFAULT_FILE_NAME = 'Untitled' export const DEFAULT_FILE_NAME = 'Untitled'
/** The default name for a tutorial project */ /** The default name for a tutorial project */
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn' export const ONBOARDING_PROJECT_NAME = 'tutorial-project'
/** /**
* The default starting constant name for various modeling operations. * The default starting constant name for various modeling operations.
* These are used to generate unique names for new objects. * These are used to generate unique names for new objects.
@ -194,3 +194,8 @@ export const IS_PLAYWRIGHT_KEY = 'playwright'
/** Should we mark all the ML features as "beta"? */ /** Should we mark all the ML features as "beta"? */
export const IS_ML_EXPERIMENTAL = true export const IS_ML_EXPERIMENTAL = true
export const ML_EXPERIMENTAL_MESSAGE = 'This feature is experimental.' export const ML_EXPERIMENTAL_MESSAGE = 'This feature is experimental.'
/**
* HTML data-* attribute for tagging elements for highlighting
* while in the onboarding flow.
*/
export const ONBOARDING_DATA_ATTRIBUTE = 'onboarding-id'

View File

@ -1,6 +1,18 @@
import bracket from '@public/kcl-samples/bracket/main.kcl?raw' import bracket from '@public/kcl-samples/bracket/main.kcl?raw'
import fanAssembly from '@public/kcl-samples/axial-fan/main.kcl?raw'
import fanHousingOriginal from '@public/kcl-samples/axial-fan/fan-housing.kcl?raw'
import fanFan from '@public/kcl-samples/axial-fan/fan.kcl?raw'
import fanMotor from '@public/kcl-samples/axial-fan/motor.kcl?raw'
import fanParameters from '@public/kcl-samples/axial-fan/parameters.kcl?raw'
export { bracket } export { bracket }
export const fanParts = [
{ requestedFileName: 'main.kcl', requestedCode: fanAssembly },
{ requestedFileName: 'fan.kcl', requestedCode: fanFan },
{ requestedFileName: 'motor.kcl', requestedCode: fanMotor },
{ requestedFileName: 'parameters.kcl', requestedCode: fanParameters },
{ requestedFileName: 'fan-housing.kcl', requestedCode: fanHousingOriginal },
] as const
/** /**
* @throws Error if the search text is not found in the example code. * @throws Error if the search text is not found in the example code.
@ -29,3 +41,533 @@ export const bracketWidthConstantLine = findLineInExampleCode({
export const bracketThicknessCalculationLine = findLineInExampleCode({ export const bracketThicknessCalculationLine = findLineInExampleCode({
searchText: 'thickness =', searchText: 'thickness =',
}) })
const fanHousing = `
// Fan Housing
// The plastic housing that contains the fan and the motor
// Set units
@settings(defaultLengthUnit = mm)
// Define Parameters
export fanSize = 120
export fanHeight = 25
export mountingHoleSpacing = 105
export mountingHoleSize = 4.5
// Model the housing which holds the motor, the fan, and the mounting provisions
// Bottom mounting face
bottomFaceSketch = startSketchOn(XY)
|> startProfile(at = [-fanSize / 2, -fanSize / 2])
|> angledLine(angle = 0, length = fanSize, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) + 90, length = fanSize, tag = $rectangleSegmentB001)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD001)
|> close()
|> subtract2d(tool = circle(center = [0, 0], radius = 4))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> extrude(length = 4)
// Add large openings to the bottom face to allow airflow through the fan
airflowPattern = startSketchOn(bottomFaceSketch, face = END)
|> startProfile(at = [fanSize * 7 / 25, -fanSize * 9 / 25])
|> angledLine(angle = 140, length = fanSize * 12 / 25, tag = $seg01)
|> tangentialArc(radius = fanSize * 1 / 50, angle = 90)
|> angledLine(angle = -130, length = fanSize * 8 / 25)
|> tangentialArc(radius = fanSize * 1 / 50, angle = 90)
|> angledLine(angle = segAng(seg01) + 180, length = fanSize * 2 / 25)
|> tangentialArc(radius = fanSize * 8 / 25, angle = 40)
|> xLine(length = fanSize * 3 / 25)
|> tangentialArc(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
|> patternCircular2d(
instances = 4,
center = [0, 0],
arcDegrees = 360,
rotateDuplicates = true,
)
|> extrude(length = -4)
// Create the middle segment of the fan housing body
housingMiddleLength = fanSize / 3
housingMiddleRadius = fanSize / 3 - 1
bodyMiddle = startSketchOn(bottomFaceSketch, face = END)
|> startProfile(at = [
housingMiddleLength / 2,
-housingMiddleLength / 2 - housingMiddleRadius
])
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> yLine(length = housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> xLine(length = -housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> yLine(length = -housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> extrude(length = fanHeight - 4 - 4)
// Cut a hole in the body to accommodate the fan
bodyFanHole = startSketchOn(bodyMiddle, face = END)
|> circle(center = [0, 0], radius = fanSize * 23 / 50)
|> extrude(length = -(fanHeight - 4 - 4))
// Top mounting face. Cut a hole in the face to accommodate the fan
topFaceSketch = startSketchOn(bodyMiddle, face = END)
topHoles = startProfile(topFaceSketch, at = [-fanSize / 2, -fanSize / 2])
|> angledLine(angle = 0, length = fanSize, tag = $rectangleSegmentA002)
|> angledLine(angle = segAng(rectangleSegmentA002) + 90, length = fanSize, tag = $rectangleSegmentB002)
|> angledLine(angle = segAng(rectangleSegmentA002), length = -segLen(rectangleSegmentA002), tag = $rectangleSegmentC002)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD002)
|> close()
|> subtract2d(tool = circle(center = [0, 0], radius = fanSize * 23 / 50))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> extrude(length = 4)
// Create a housing for the electric motor to sit
motorHousing = startSketchOn(bottomFaceSketch, face = END)
|> circle(center = [0, 0], radius = 11.2)
|> extrude(length = 16)
startSketchOn(motorHousing, face = END)
|> circle(center = [0, 0], radius = 10)
|> extrude(length = -16)
|> appearance(color = "#a55e2c")
|> fillet(
radius = abs(fanSize - mountingHoleSpacing) / 2,
tags = [
getNextAdjacentEdge(rectangleSegmentA001),
getNextAdjacentEdge(rectangleSegmentB001),
getNextAdjacentEdge(rectangleSegmentC001),
getNextAdjacentEdge(rectangleSegmentD001),
getNextAdjacentEdge(rectangleSegmentA002),
getNextAdjacentEdge(rectangleSegmentB002),
getNextAdjacentEdge(rectangleSegmentC002),
getNextAdjacentEdge(rectangleSegmentD002)
],
)
`
export const modifiedFanHousing = `// Fan Housing
// The plastic housing that contains the fan and the motor
// Set units
@settings(defaultLengthUnit = mm)
export fanSize = 150
export fanHeight = 30
export mountingHoleSpacing = 105
export mountingHoleSize = 4.5
// Model the housing which holds the motor, the fan, and the mounting provisions
// Bottom mounting face
bottomFaceSketch = startSketchOn(XY)
|> startProfile(at = [-fanSize / 2, -fanSize / 2])
|> angledLine(angle = 0, length = fanSize, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) + 90, length = fanSize, tag = $rectangleSegmentB001)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD001)
|> close()
|> subtract2d(tool = circle(center = [0, 0], radius = 4))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> extrude(length = 4)
// Add large openings to the bottom face to allow airflow through the fan
airflowPattern = startSketchOn(bottomFaceSketch, face = END)
|> startProfile(at = [fanSize * 7 / 25, -fanSize * 9 / 25])
|> angledLine(angle = 140, length = fanSize * 12 / 25, tag = $seg01)
|> tangentialArc(radius = fanSize * 1 / 50, angle = 90)
|> angledLine(angle = -130, length = fanSize * 8 / 25)
|> tangentialArc(radius = fanSize * 1 / 50, angle = 90)
|> angledLine(angle = segAng(seg01) + 180, length = fanSize * 2 / 25)
|> tangentialArc(radius = fanSize * 8 / 25, angle = 40)
|> xLine(length = fanSize * 3 / 25)
|> tangentialArc(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
|> patternCircular2d(
instances = 4,
center = [0, 0],
arcDegrees = 360,
rotateDuplicates = true,
)
|> extrude(length = -4)
// Create the middle segment of the fan housing body
housingMiddleLength = fanSize / 3
housingMiddleRadius = fanSize / 3 - 1
bodyMiddle = startSketchOn(bottomFaceSketch, face = END)
|> startProfile(at = [
housingMiddleLength / 2,
-housingMiddleLength / 2 - housingMiddleRadius
])
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> yLine(length = housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> xLine(length = -housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> yLine(length = -housingMiddleLength)
|> tangentialArc(radius = housingMiddleRadius, angle = 90)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> extrude(length = fanHeight - 4 - 4)
// Cut a hole in the body to accommodate the fan
bodyFanHole = startSketchOn(bodyMiddle, face = END)
|> circle(center = [0, 0], radius = fanSize * 23 / 50)
|> extrude(length = -(fanHeight - 4 - 4))
// Top mounting face. Cut a hole in the face to accommodate the fan
topFaceSketch = startSketchOn(bodyMiddle, face = END)
topHoles = startProfile(topFaceSketch, at = [-fanSize / 2, -fanSize / 2])
|> angledLine(angle = 0, length = fanSize, tag = $rectangleSegmentA002)
|> angledLine(angle = segAng(rectangleSegmentA002) + 90, length = fanSize, tag = $rectangleSegmentB002)
|> angledLine(angle = segAng(rectangleSegmentA002), length = -segLen(rectangleSegmentA002), tag = $rectangleSegmentC002)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD002)
|> close()
|> subtract2d(tool = circle(center = [0, 0], radius = fanSize * 23 / 50))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> subtract2d(tool = circle(
center = [
-mountingHoleSpacing / 2,
-mountingHoleSpacing / 2
],
radius = mountingHoleSize / 2,
))
|> extrude(length = 4)
// Create a housing for the electric motor to sit
motorHousing = startSketchOn(bottomFaceSketch, face = END)
|> circle(center = [0, 0], radius = 11.2)
|> extrude(length = 16)
startSketchOn(motorHousing, face = END)
|> circle(center = [0, 0], radius = 10)
|> extrude(length = -16)
|> appearance(color = "#800080") // Changed color to purple
|> fillet(
radius = abs(fanSize - mountingHoleSpacing) / 2,
tags = [
getNextAdjacentEdge(rectangleSegmentA001),
getNextAdjacentEdge(rectangleSegmentB001),
getNextAdjacentEdge(rectangleSegmentC001),
getNextAdjacentEdge(rectangleSegmentD001),
getNextAdjacentEdge(rectangleSegmentA002),
getNextAdjacentEdge(rectangleSegmentB002),
getNextAdjacentEdge(rectangleSegmentC002),
getNextAdjacentEdge(rectangleSegmentD002)
],
)
`
/**
* GOTCHA: this browser sample is a reconstructed assembly, made by
* concatenating the individual parts together. If the original axial-fan
* KCL sample is updated, it can lead to breaking this export.
*/
export const browserAxialFan = `
${fanHousing}
// Fan
// Spinning axial fan that moves airflow
// Model the center of the fan
fanCenter = startSketchOn(XZ)
|> startProfile(at = [-0.0001, fanHeight])
|> xLine(endAbsolute = -15 + 1.5)
|> tangentialArc(radius = 1.5, angle = 90)
|> yLine(endAbsolute = 4.5)
|> xLine(endAbsolute = -13)
|> yLine(endAbsolute = profileStartY(%) - 5)
|> tangentialArc(radius = 1, angle = -90)
|> xLine(endAbsolute = -1)
|> yLine(length = 2)
|> xLine(length = -0.15)
|> line(endAbsolute = [
profileStartX(%) - 1,
profileStartY(%) - 1.4
])
|> xLine(endAbsolute = profileStartX(%))
|> yLine(endAbsolute = profileStartY(%))
|> close()
|> revolve(axis = {
direction = [0.0, 1.0],
origin = [0.0, 0.0]
})
|> appearance(color = "#f3e2d8")
// Create a function for a lofted fan blade cross section that rotates about the center hub of the fan
fn fanBlade(offsetHeight, startAngle: number(deg)) {
fanBlade = startSketchOn(offsetPlane(XY, offset = offsetHeight))
|> startProfile(at = [
15 * cos(startAngle),
15 * sin(startAngle)
])
|> arc(angleStart = startAngle, angleEnd = startAngle + 14, radius = 15)
|> arc(
endAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 20),
fanSize * 22 / 50 * sin(startAngle - 20)
],
interiorAbsolute = [
fanSize * 11 / 50 * cos(startAngle + 3),
fanSize * 11 / 50 * sin(startAngle + 3)
],
)
|> arc(
endAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 24),
fanSize * 22 / 50 * sin(startAngle - 24)
],
interiorAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 22),
fanSize * 22 / 50 * sin(startAngle - 22)
],
)
|> arc(
endAbsolute = [profileStartX(%), profileStartY(%)],
interiorAbsolute = [
fanSize * 11 / 50 * cos(startAngle - 5),
fanSize * 11 / 50 * sin(startAngle - 5)
],
)
|> close()
return fanBlade
}
// Loft the fan blade cross sections into a single blade, then pattern them about the fan center
crossSections = [
fanBlade(offsetHeight = 4.5, startAngle = 50),
fanBlade(offsetHeight = (fanHeight - 2 - 4) / 2, startAngle = 30),
fanBlade(offsetHeight = fanHeight - 2, startAngle = 0)
]
loft(crossSections)
|> appearance(color = "#f3e2d8")
|> patternCircular3d(
instances = 9,
axis = [0, 0, 1],
center = [0, 0, 0],
arcDegrees = 360,
rotateDuplicates = true,
)
// Motor
// A small electric motor to power the fan
// Model the motor body and stem
topFacePlane = offsetPlane(XY, offset = 4)
motorBody = startSketchOn(topFacePlane)
|> circle(center = [0, 0], radius = 10, tag = $seg04)
|> extrude(length = 17)
|> appearance(color = "#021b55")
|> fillet(radius = 2, tags = [getOppositeEdge(seg04), seg04])
startSketchOn(offsetPlane(XY, offset = 21))
|> circle(center = [0, 0], radius = 1)
|> extrude(length = 3.8)
|> appearance(color = "#dbc89e")
`
/**
* GOTCHA: this browser sample is a reconstructed assembly, made by
* concatenating the individual parts together. If the original axial-fan
* KCL sample is updated, it can lead to breaking this export.
*/
export const browserAxialFanAfterTextToCad = `
${modifiedFanHousing}
// Fan
// Spinning axial fan that moves airflow
// Model the center of the fan
fanCenter = startSketchOn(XZ)
|> startProfile(at = [-0.0001, fanHeight])
|> xLine(endAbsolute = -15 + 1.5)
|> tangentialArc(radius = 1.5, angle = 90)
|> yLine(endAbsolute = 4.5)
|> xLine(endAbsolute = -13)
|> yLine(endAbsolute = profileStartY(%) - 5)
|> tangentialArc(radius = 1, angle = -90)
|> xLine(endAbsolute = -1)
|> yLine(length = 2)
|> xLine(length = -0.15)
|> line(endAbsolute = [
profileStartX(%) - 1,
profileStartY(%) - 1.4
])
|> xLine(endAbsolute = profileStartX(%))
|> yLine(endAbsolute = profileStartY(%))
|> close()
|> revolve(axis = {
direction = [0.0, 1.0],
origin = [0.0, 0.0]
})
|> appearance(color = "#f3e2d8")
// Create a function for a lofted fan blade cross section that rotates about the center hub of the fan
fn fanBlade(offsetHeight, startAngle: number(deg)) {
fanBlade = startSketchOn(offsetPlane(XY, offset = offsetHeight))
|> startProfile(at = [
15 * cos(startAngle),
15 * sin(startAngle)
])
|> arc(angleStart = startAngle, angleEnd = startAngle + 14, radius = 15)
|> arc(
endAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 20),
fanSize * 22 / 50 * sin(startAngle - 20)
],
interiorAbsolute = [
fanSize * 11 / 50 * cos(startAngle + 3),
fanSize * 11 / 50 * sin(startAngle + 3)
],
)
|> arc(
endAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 24),
fanSize * 22 / 50 * sin(startAngle - 24)
],
interiorAbsolute = [
fanSize * 22 / 50 * cos(startAngle - 22),
fanSize * 22 / 50 * sin(startAngle - 22)
],
)
|> arc(
endAbsolute = [profileStartX(%), profileStartY(%)],
interiorAbsolute = [
fanSize * 11 / 50 * cos(startAngle - 5),
fanSize * 11 / 50 * sin(startAngle - 5)
],
)
|> close()
return fanBlade
}
// Loft the fan blade cross sections into a single blade, then pattern them about the fan center
crossSections = [
fanBlade(offsetHeight = 4.5, startAngle = 50),
fanBlade(offsetHeight = (fanHeight - 2 - 4) / 2, startAngle = 30),
fanBlade(offsetHeight = fanHeight - 2, startAngle = 0)
]
loft(crossSections)
|> appearance(color = "#f3e2d8")
|> patternCircular3d(
instances = 9,
axis = [0, 0, 1],
center = [0, 0, 0],
arcDegrees = 360,
rotateDuplicates = true,
)
// Motor
// A small electric motor to power the fan
// Model the motor body and stem
topFacePlane = offsetPlane(XY, offset = 4)
motorBody = startSketchOn(topFacePlane)
|> circle(center = [0, 0], radius = 10, tag = $seg04)
|> extrude(length = 17)
|> appearance(color = "#021b55")
|> fillet(radius = 2, tags = [getOppositeEdge(seg04), seg04])
startSketchOn(offsetPlane(XY, offset = 21))
|> circle(center = [0, 0], radius = 1)
|> extrude(length = 3.8)
|> appearance(color = "#dbc89e")
`

View File

@ -1,22 +1,75 @@
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus' import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { isDesktop } from '@src/lib/isDesktop'
export const ONBOARDING_SUBPATHS: Record<string, OnboardingStatus> = { export type OnboardingPath = OnboardingStatus & `/${string}`
INDEX: '/', export type DesktopOnboardingPath = OnboardingPath & `/desktop${string}`
CAMERA: '/camera', export type BrowserOnboardingPath = OnboardingPath & `/browser${string}`
STREAMING: '/streaming',
EDITOR: '/editor',
PARAMETRIC_MODELING: '/parametric-modeling',
INTERACTIVE_NUMBERS: '/interactive-numbers',
COMMAND_K: '/command-k',
USER_MENU: '/user-menu',
PROJECT_MENU: '/project-menu',
EXPORT: '/export',
SKETCHING: '/sketching',
FUTURE_WORK: '/future-work',
} as const
export const isOnboardingSubPath = ( // companion to "desktop routes" in `OnboardingRoutes` enum in Rust
export const desktopOnboardingPaths: Record<string, DesktopOnboardingPath> = {
welcome: '/desktop',
scene: '/desktop/scene',
toolbar: '/desktop/toolbar',
textToCadWelcome: '/desktop/text-to-cad',
textToCadPrompt: '/desktop/text-to-cad-prompt',
featureTreePane: '/desktop/feature-tree-pane',
codePane: '/desktop/code-pane',
projectFilesPane: '/desktop/project-pane',
otherPanes: '/desktop/other-panes',
promptToEditWelcome: '/desktop/prompt-to-edit',
promptToEditPrompt: '/desktop/prompt-to-edit-prompt',
promptToEditResult: '/desktop/prompt-to-edit-result',
imports: '/desktop/imports',
exports: '/desktop/exports',
conclusion: '/desktop/conclusion',
}
// companion to "web routes" in `OnboardingRoutes` enum in Rust
export const browserOnboardingPaths: Record<string, BrowserOnboardingPath> = {
welcome: '/browser',
scene: '/browser/scene',
toolbar: '/browser/toolbar',
textToCadWelcome: '/browser/text-to-cad',
textToCadPrompt: '/browser/text-to-cad-prompt',
featureTreePane: '/browser/feature-tree-pane',
promptToEditWelcome: '/browser/prompt-to-edit',
promptToEditPrompt: '/browser/prompt-to-edit-prompt',
promptToEditResult: '/browser/prompt-to-edit-result',
conclusion: '/browser/conclusion',
}
export const onboardingPaths = {
desktop: desktopOnboardingPaths,
browser: browserOnboardingPaths,
}
export const onboardingPathsArray = Object.values(onboardingPaths).flatMap(
(p) => Object.values(p)
)
/** Whatever the first onboarding path on the current platform is. */
export const onboardingStartPath = Object.values(
onboardingPaths[isDesktop() ? 'desktop' : 'browser']
)[0]
export const isOnboardingPath = (input: string): input is OnboardingStatus => {
return Object.values(onboardingPaths)
.flatMap((o) => Object.values(o))
.includes(input as OnboardingPath)
}
export const isDesktopOnboardingPath = (
input: string input: string
): input is OnboardingStatus => { ): input is OnboardingStatus => {
return Object.values(ONBOARDING_SUBPATHS).includes(input as OnboardingStatus) return Object.values(onboardingPaths.desktop).includes(
input as DesktopOnboardingPath
)
}
export const isBrowserOnboardingPath = (
input: string
): input is OnboardingStatus => {
return Object.values(onboardingPaths.browser).includes(
input as BrowserOnboardingPath
)
} }

View File

@ -10,21 +10,6 @@ import {
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { err } from '@src/lib/trap' import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
return Object.fromEntries(
Object.entries(routesObject).map(([constName, path]) => [
constName,
prepend + path,
])
)
}
type OnboardingPaths = {
[K in keyof typeof ONBOARDING_SUBPATHS]: `/onboarding${(typeof ONBOARDING_SUBPATHS)[K]}`
}
const SETTINGS = '/settings' const SETTINGS = '/settings'
@ -44,9 +29,7 @@ export const PATHS = {
SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const, SETTINGS_PROJECT: `${SETTINGS}?tab=project` as const,
SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const, SETTINGS_KEYBINDINGS: `${SETTINGS}?tab=keybindings` as const,
SIGN_IN: '/signin', SIGN_IN: '/signin',
ONBOARDING: prependRoutes(ONBOARDING_SUBPATHS)( ONBOARDING: '/onboarding',
'/onboarding'
) as OnboardingPaths,
TELEMETRY: '/telemetry', TELEMETRY: '/telemetry',
} as const } as const
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}` export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`

View File

@ -12,7 +12,12 @@ import {
} from '@src/lib/constants' } from '@src/lib/constants'
import { getProjectInfo } from '@src/lib/desktop' import { getProjectInfo } from '@src/lib/desktop'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { BROWSER_PATH, PATHS, getProjectMetaByRouteId } from '@src/lib/paths' import {
BROWSER_PATH,
PATHS,
getProjectMetaByRouteId,
safeEncodeForRouterPaths,
} from '@src/lib/paths'
import { import {
loadAndValidateSettings, loadAndValidateSettings,
readLocalStorageAppSettingsFile, readLocalStorageAppSettingsFile,
@ -63,6 +68,17 @@ export const fileLoader: LoaderFunction = async (
} }
} }
// If we are navigating to the project and want to navigate to its
// default file, redirect to it keeping everything else in the URL the same.
if (projectPath && !currentFileName && fileExists && params.id) {
const encodedId = safeEncodeForRouterPaths(params.id)
const requestUrlWithDefaultFile = routerData.request.url.replace(
encodedId,
safeEncodeForRouterPaths(fallbackFile)
)
return redirect(requestUrlWithDefaultFile)
}
if (!fileExists || !currentFileName || !currentFilePath || !projectName) { if (!fileExists || !currentFileName || !currentFilePath || !projectName) {
return redirect( return redirect(
`${PATHS.FILE}/${encodeURIComponent( `${PATHS.FILE}/${encodeURIComponent(

View File

@ -99,6 +99,7 @@ export const systemIOMachine = setup({
files: RequestedKCLFile[] files: RequestedKCLFile[]
requestedProjectName: string requestedProjectName: string
override?: boolean override?: boolean
requestedSubRoute?: string
} }
} }
| { | {
@ -313,8 +314,9 @@ export const systemIOMachine = setup({
message: string message: string
fileName: string fileName: string
projectName: string projectName: string
subRoute: string
}> => { }> => {
return { message: '', fileName: '', projectName: '' } return { message: '', fileName: '', projectName: '', subRoute: '' }
} }
), ),
[SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise( [SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise(
@ -326,13 +328,15 @@ export const systemIOMachine = setup({
files: RequestedKCLFile[] files: RequestedKCLFile[]
rootContext: AppMachineContext rootContext: AppMachineContext
requestedProjectName: string requestedProjectName: string
requestedSubRoute?: string
} }
}): Promise<{ }): Promise<{
message: string message: string
fileName: string fileName: string
projectName: string projectName: string
subRoute: string
}> => { }> => {
return { message: '', fileName: '', projectName: '' } return { message: '', fileName: '', projectName: '', subRoute: '' }
} }
), ),
}, },
@ -550,7 +554,13 @@ export const systemIOMachine = setup({
// Clear on web? not desktop // Clear on web? not desktop
actions: [ actions: [
assign({ assign({
requestedFileName: ({ context, event }) => { requestedProjectName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.done_importFileFromURL)
return {
name: event.output.projectName,
}
},
requestedFileName: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.done_importFileFromURL) assertEvent(event, SystemIOMachineEvents.done_importFileFromURL)
// Gotcha: file could have an ending of .kcl... // Gotcha: file could have an ending of .kcl...
const file = event.output.fileName.endsWith('.kcl') const file = event.output.fileName.endsWith('.kcl')
@ -650,6 +660,7 @@ export const systemIOMachine = setup({
rootContext: self.system.get('root').getSnapshot().context, rootContext: self.system.get('root').getSnapshot().context,
requestedProjectName: event.data.requestedProjectName, requestedProjectName: event.data.requestedProjectName,
override: event.data.override, override: event.data.override,
requestedSubRoute: event.data.requestedSubRoute,
} }
}, },
onDone: { onDone: {
@ -657,7 +668,10 @@ export const systemIOMachine = setup({
actions: [ actions: [
assign({ assign({
requestedProjectName: ({ event }) => { requestedProjectName: ({ event }) => {
return { name: event.output.projectName } return {
name: event.output.projectName,
subRoute: event.output.subRoute,
}
}, },
}), }),
SystemIOMachineActions.toastSuccess, SystemIOMachineActions.toastSuccess,

View File

@ -90,6 +90,7 @@ const sharedBulkCreateWorkflow = async ({
message, message,
fileName: '', fileName: '',
projectName: '', projectName: '',
subRoute: 'subRoute' in input ? input.subRoute : '',
} }
} }
@ -336,7 +337,11 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
rootContext: AppMachineContext rootContext: AppMachineContext
} }
}) => { }) => {
return await sharedBulkCreateWorkflow({ input }) const message = await sharedBulkCreateWorkflow({ input })
return {
...message,
subRoute: '',
}
} }
), ),
[SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise( [SystemIOMachineActors.bulkCreateKCLFilesAndNavigateToProject]: fromPromise(
@ -349,6 +354,7 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
rootContext: AppMachineContext rootContext: AppMachineContext
requestedProjectName: string requestedProjectName: string
override?: boolean override?: boolean
requestedSubRoute?: string
} }
}) => { }) => {
const message = await sharedBulkCreateWorkflow({ const message = await sharedBulkCreateWorkflow({
@ -357,8 +363,11 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
override: input.override, override: input.override,
}, },
}) })
message.projectName = input.requestedProjectName return {
return message ...message,
projectName: input.requestedProjectName,
subRoute: input.requestedSubRoute || '',
}
} }
), ),
}, },

View File

@ -84,7 +84,7 @@ export type SystemIOContext = {
/** has the application gone through the initialization of systemIOMachine at least once. /** has the application gone through the initialization of systemIOMachine at least once.
* this is required to prevent chokidar from spamming invalid events during initialization. */ * this is required to prevent chokidar from spamming invalid events during initialization. */
hasListedProjects: boolean hasListedProjects: boolean
requestedProjectName: { name: string } requestedProjectName: { name: string; subRoute?: string }
requestedFileName: { project: string; file: string; subRoute?: string } requestedFileName: { project: string; file: string; subRoute?: string }
canReadWriteProjectDirectory: { value: boolean; error: unknown } canReadWriteProjectDirectory: { value: boolean; error: unknown }
clearURLParams: { value: boolean } clearURLParams: { value: boolean }
@ -106,7 +106,9 @@ export type RequestedKCLFile = {
export const waitForIdleState = async ({ export const waitForIdleState = async ({
systemIOActor, systemIOActor,
}: { systemIOActor: ActorRefFrom<typeof systemIOMachine> }) => { }: {
systemIOActor: ActorRefFrom<typeof systemIOMachine>
}) => {
// Check if already idle before setting up subscription // Check if already idle before setting up subscription
if (systemIOActor.getSnapshot().matches(SystemIOMachineStates.idle)) { if (systemIOActor.getSnapshot().matches(SystemIOMachineStates.idle)) {
return Promise.resolve() return Promise.resolve()

View File

@ -0,0 +1,498 @@
import {
type BrowserOnboardingPath,
browserOnboardingPaths,
} from '@src/lib/onboardingPaths'
import { useRouteLoaderData, type RouteObject } from 'react-router-dom'
import {
isModelingCmdGroupReady,
OnboardingButtons,
OnboardingCard,
useAdvanceOnboardingOnFormSubmit,
useOnboardingHighlight,
useOnboardingPanes,
useOnModelingCmdGroupReadyOnce,
} from '@src/routes/Onboarding/utils'
import { Spinner } from '@src/components/Spinner'
import {
ONBOARDING_DATA_ATTRIBUTE,
BROWSER_PROJECT_NAME,
PROJECT_ENTRYPOINT,
} from '@src/lib/constants'
import { PATHS, joinRouterPaths } from '@src/lib/paths'
import type { Selections } from '@src/lib/selections'
import { systemIOActor, commandBarActor } from '@src/lib/singletons'
import type { IndexLoaderData } from '@src/lib/types'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { useEffect, useState } from 'react'
import { VITE_KC_SITE_BASE_URL } from '@src/env'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import {
browserAxialFan,
browserAxialFanAfterTextToCad,
} from '@src/lib/exampleKcl'
type BrowserOnboaringRoute = RouteObject & {
path: keyof typeof browserOnboardingPaths
}
/**
* This is the mapping between browser onboarding paths and the components that will be rendered.
* All components are defined below in this file.
*
* Browser onboarding content is completely separate from desktop onboarding content.
*/
const browserOnboardingComponents: Record<BrowserOnboardingPath, JSX.Element> =
{
'/browser': <Welcome />,
'/browser/scene': <Scene />,
'/browser/toolbar': <Toolbar />,
'/browser/text-to-cad': <TextToCad />,
'/browser/text-to-cad-prompt': <TextToCadPrompt />,
'/browser/feature-tree-pane': <FeatureTreePane />,
'/browser/prompt-to-edit': <PromptToEdit />,
'/browser/prompt-to-edit-prompt': <PromptToEditPrompt />,
'/browser/prompt-to-edit-result': <PromptToEditResult />,
'/browser/conclusion': <OnboardingConclusion />,
}
function Welcome() {
const thisOnboardingStatus: BrowserOnboardingPath = '/browser'
// Ensure panes are closed
useOnboardingPanes()
// Things that happen when we load this route
useEffect(() => {
// Overwrite the code with the browser-version of the axial-fan example
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: BROWSER_PROJECT_NAME,
requestedFileNameWithExtension: PROJECT_ENTRYPOINT,
requestedCode: browserAxialFan,
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="fixed inset-0 z-50 grid items-end justify-center p-2">
<OnboardingCard>
<h1 className="text-xl font-bold">Welcome to Zoo Design Studio</h1>
<p className="my-4">
Here is an axial fan that was made in Zoo Design Studio. It's a part
of a larger CPU cooler assembly sample you can view in the desktop
app, which supports multiple-part assemblies.
</p>
<p className="my-4">
Lets walk through the basics of how to get started, and how you can
use several tools at your disposal to create great designs.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function Scene() {
const thisOnboardingStatus: BrowserOnboardingPath = '/browser/scene'
// Things that happen when we load this route
useEffect(() => {
// Navigate to the `main.kcl` file
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: BROWSER_PROJECT_NAME,
requestedFileNameWithExtension: PROJECT_ENTRYPOINT,
requestedCode: '',
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
// Ensure panes are closed
useOnboardingPanes()
return (
<div className="pointer-events-none fixed inset-0 z-50 grid items-end justify-center p-2">
<OnboardingCard className="pointer-events-auto">
<h1 className="text-xl font-bold">Scene</h1>
<p className="my-4">
Here is a blank scene. There are three default planes shown when the
scene is empty. Try right-clicking and dragging to orbit around, and
scroll to zoom in and out.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function Toolbar() {
// Highlight the toolbar if it's present
useOnboardingHighlight('toolbar')
// Ensure panes are closed
useOnboardingPanes()
return (
<div className="fixed inset-0 z-[99] grid items-start justify-center p-16">
<OnboardingCard>
<h1 className="text-xl font-bold">This is the toolbar</h1>
<p className="my-4">
You can perform modeling and sketching actions by clicking any of the
tools.
</p>
<OnboardingButtons currentSlug="/browser/toolbar" platform="browser" />
</OnboardingCard>
</div>
)
}
function TextToCad() {
// Highlight the text-to-cad button if it's present
useOnboardingHighlight('ai-group')
// Ensure panes are closed
useOnboardingPanes()
return (
<div className="fixed inset-0 z-50 grid items-start justify-center p-16">
<OnboardingCard>
<h1 className="text-xl font-bold">Text-to-CAD</h1>
<p className="my-4">
This last button is Text-to-CAD. This allows you to write up a
description of what you want, and our AI will generate the CAD for
you. Text-to-CAD is currently in an experimental stage. We are
improving it every day.
</p>
<p className="my-4">
<strong>One</strong> Text-to-CAD generation costs{' '}
<strong>one credit per minute</strong>, rounded up to the nearest
minute. A large majority of Text-to-CAD generations take under a
minute. If you are on the free plan, you get 20 free credits per
month. With any of our paid plans, you get unlimited Text-to-CAD
generations.
</p>
<p className="my-4">
Lets walk through an example of how to use Text-to-CAD.
</p>
<OnboardingButtons
currentSlug="/browser/text-to-cad"
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function TextToCadPrompt() {
const thisOnboardingStatus: BrowserOnboardingPath =
'/browser/text-to-cad-prompt'
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const prompt =
'Design a fan housing for a CPU cooler for a 120mm diameter fan with four holes for retaining clips.'
// Ensure panes are closed
useOnboardingPanes()
// Enter the text-to-cad flow with a prebaked prompt
useEffect(() => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'existingProject',
projectName: loaderData?.project?.name,
prompt,
},
},
})
}, [loaderData?.project?.name])
// Make it so submitting the command just advances the onboarding
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus)
return (
<div className="fixed inset-0 z-[99] grid items-center justify-center">
<OnboardingCard>
<h1 className="text-xl font-bold">Text-to-CAD prompt</h1>
<p className="my-4">
When you click the Text-to-CAD button, it opens the command palette to
where you can input a text prompt. To save you a Text-to-CAD
generation credit, we are going to use a pre-rolled Text-to-CAD prompt
for this example. Click next to see an example of what Text-to-CAD can
generate.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function FeatureTreePane() {
const thisOnboardingStatus: BrowserOnboardingPath =
'/browser/feature-tree-pane'
// Highlight the feature tree pane button if it's present
useOnboardingHighlight('feature-tree-pane-button')
// Open the feature tree pane on mount, close on unmount
useOnboardingPanes(['feature-tree'])
// Overwrite the code with the "generated" KCL
useEffect(() => {
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: BROWSER_PROJECT_NAME,
requestedFileNameWithExtension: PROJECT_ENTRYPOINT,
requestedCode: browserAxialFan,
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="fixed inset-0 z-[99] p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">CPU Fan Housing</h1>
<p className="my-4">
This is an example of a generated CAD model; it's the same model we
showed you at the start. We skipped the real generation for this
tutorial, but normally you'll be asked to approve the generation
first.
</p>
<p className="my-4">
To the left are the panes. We have opened the feature tree pane for
you. The feature tree pane displays all the CAD functions that were
performed to create this part. You can double click feature tree items
to edit their parameters.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function PromptToEdit() {
const thisOnboardingStatus: BrowserOnboardingPath = '/browser/prompt-to-edit'
// Click the text-to-cad dropdown button if it's available
useEffect(() => {
const dropdownButton = document.querySelector(
`[data-${ONBOARDING_DATA_ATTRIBUTE}="ai-dropdown-button"]`
)
if (dropdownButton === null) {
console.error(
`Expected dropdown is not present in onboarding step '${thisOnboardingStatus}'`
)
return
}
if (dropdownButton instanceof HTMLButtonElement) {
dropdownButton.click()
}
}, [])
// Close the panes on mount, close on unmount
useOnboardingPanes()
// Make it so submitting the command just advances the onboarding
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus)
return (
<div className="fixed inset-0 z-50 grid items-center justify-center p-16">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Modify with Zoo Text-to-CAD</h1>
<p className="my-4">
Text-to-CAD not only can <strong>create</strong> a part, but also{' '}
<strong>modify</strong> an existing part. In the dropdown, youll see
Modify with Zoo Text-to-CAD. Once clicked, youll describe the
change you want for your part, and our AI will generate the change.
Once again, this will cost <strong>one credit per minute</strong> it
took to generate. Once again, most of the time, this is under a
minute.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function PromptToEditPrompt() {
const thisOnboardingStatus: BrowserOnboardingPath =
'/browser/prompt-to-edit-prompt'
const prompt =
'Change the housing to be for a 150 mm diameter fan, make it 30 mm tall, and change the color to purple.'
// Ensure panes are closed
useOnboardingPanes()
// Enter the prompt-to-edit flow with a prebaked prompt
const [isReady, setIsReady] = useState(
isModelingCmdGroupReady(commandBarActor.getSnapshot())
)
useOnModelingCmdGroupReadyOnce(() => {
setIsReady(true)
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',
name: 'Prompt-to-edit',
argDefaultValues: {
selection: {
graphSelections: [],
otherSelections: [],
} satisfies Selections,
prompt,
},
},
})
}, [])
// Make it so submitting the command just advances the onboarding
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus)
return (
<div className="fixed inset-0 z-[99] grid items-center justify-center">
<OnboardingCard className="pointer-events-auto">
<h1 className="text-xl font-bold">Modify with Text-to-CAD prompt</h1>
{!isReady && (
<p className="absolute top-0 right-0 m-4 w-fit flex items-center py-1 px-2 rounded bg-chalkboard-20 dark:bg-chalkboard-80">
<Spinner className="w-5 h-5 inline-block mr-2" />
Waiting for connection...
</p>
)}
<p className="my-4">
To save you a credit, we are using a pre-rolled Text-to-CAD prompt to
edit your existing fan housing. You can see the prompt in the window
above. Click next to see an example of what modifying with Text-to-CAD
would look like.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function PromptToEditResult() {
const thisOnboardingStatus: BrowserOnboardingPath =
'/browser/prompt-to-edit-result'
// Open the code pane on mount, close on unmount
useOnboardingPanes(['code'])
// Overwrite the code with the "generated" KCL
useEffect(() => {
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: BROWSER_PROJECT_NAME,
requestedFileNameWithExtension: PROJECT_ENTRYPOINT,
requestedCode: browserAxialFanAfterTextToCad,
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="fixed inset-0 z-[99] p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Result</h1>
<p className="my-4">
This is an example of an edit that Text-to-CAD can make for you. We
skipped the real generation for this tutorial, but normally you'll be
asked to approve the generation first.
</p>
<p className="my-4">
Text-to-CAD will make changes across files in your project, so if you
have named parameters in another file that need to change to complete
your request, it is smart enough to go find their source and change
them.
</p>
<p className="my-4">
All of our Text-to-CAD capabilities are experimental, so please report
any issues to us and stay tuned for updates! We are working on it
every day.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="browser"
/>
</OnboardingCard>
</div>
)
}
function OnboardingConclusion() {
// Close the panes on mount, close on unmount
useOnboardingPanes()
return (
<div className="fixed inset-0 z-50 p-16 grid justify-center items-center">
<OnboardingCard>
<h1 className="text-xl font-bold">Download the desktop app</h1>
<p className="my-4">
We highly encourage you to{' '}
<a
onClick={openExternalBrowserIfDesktop(
`${VITE_KC_SITE_BASE_URL}/modeling-app/download/nightly`
)}
href="https://zoo.dev/modeling-app/download/nightly"
target="_blank"
rel="noopener noreferrer"
>
download our desktop app
</a>{' '}
so you can experience the full functionality of Zoo Design Studio,
including multi-part assemblies, project management, and local file
saving.
</p>
<OnboardingButtons
currentSlug="/browser/conclusion"
platform="browser"
/>
</OnboardingCard>
</div>
)
}
export const browserOnboardingRoutes: BrowserOnboaringRoute[] = [
...Object.values(browserOnboardingPaths).map((path) => ({
path,
element: browserOnboardingComponents[path],
})),
]

View File

@ -1,68 +0,0 @@
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { settingsActor, useSettings } from '@src/lib/singletons'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Units() {
const {
modeling: { mouseControls },
} = useSettings()
return (
<div className="fixed inset-0 z-50 grid items-end justify-start px-4 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-2xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<SettingsSection
title="Mouse Controls"
description="Choose what buttons you want to use on your mouse or trackpad to move around the 3D view. Try them out above and choose the one that feels most comfortable to you."
className="my-4 last-of-type:mb-12"
headingClassName="text-3xl font-bold"
>
<select
id="camera-controls"
className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
value={mouseControls.current}
onChange={(e) => {
settingsActor.send({
type: 'set.modeling.mouseControls',
data: {
level: 'user',
value: e.target.value as CameraSystem,
},
})
}}
>
{cameraSystems.map((program) => (
<option key={program} value={program}>
{program}
</option>
))}
</select>
<ul className="mx-4 my-2 text-sm leading-relaxed">
<li>
<strong>Pan:</strong>{' '}
{cameraMouseDragGuards[mouseControls.current].pan.description}
</li>
<li>
<strong>Zoom:</strong>{' '}
{cameraMouseDragGuards[mouseControls.current].zoom.description}
</li>
<li>
<strong>Rotate:</strong>{' '}
{cameraMouseDragGuards[mouseControls.current].rotate.description}
</li>
</ul>
</SettingsSection>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.CAMERA}
dismissClassName="right-auto left-full"
/>
</div>
</div>
)
}

View File

@ -1,43 +0,0 @@
import { COMMAND_PALETTE_HOTKEY } from '@src/components/CommandBar/CommandBar'
import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons, kbdClasses } from '@src/routes/Onboarding/utils'
export default function CmdK() {
const platformName = usePlatform()
return (
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-full xl:max-w-4xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<h2 className="text-2xl font-bold">Command Bar</h2>
<p className="my-4">
Press{' '}
<kbd className={kbdClasses}>
{hotkeyDisplay(COMMAND_PALETTE_HOTKEY, platformName)}
</kbd>{' '}
to open the command bar. Try changing your theme with it.
</p>
<p className="my-4">
We are working on a command bar that will allow you to quickly see and
search for any available commands. We are building Zoo Design Studio's
state management system on top of{' '}
<a
href="https://xstate.js.org/"
rel="noreferrer noopener"
target="_blank"
>
XState
</a>
. You can control settings, authentication, and file management from
the command bar, as well as a growing number of modeling commands.
</p>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.COMMAND_K} />
</div>
</div>
)
}

View File

@ -1,76 +0,0 @@
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import {
OnboardingButtons,
kbdClasses,
useDemoCode,
} from '@src/routes/Onboarding/utils'
export default function OnboardingCodeEditor() {
useDemoCode()
return (
<div className="fixed grid justify-end items-center inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto z-10 max-w-xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg h-[75vh] flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1 overflow-y-auto">
<h2 className="text-3xl font-bold">
Editing code with <span className="text-primary">kcl</span>
</h2>
<p className="my-4">
kcl is our language for describing geometry. Building our own
language is difficult, but we chose to do it to have a language
honed for spatial relationships and geometric computation. It'll
always be open-source, and we hope it can grow into a new standard
for describing parametric objects.
</p>
<p className="my-4">
The left pane is where you write your code. It's a code editor with
syntax highlighting and autocompletion for kcl. New features arrive
in kcl before they're available as point-and-click tools, so it's
good to have a link to{' '}
<a
href="https://zoo.dev/docs/kcl"
rel="noreferrer noopener"
target="_blank"
>
our kcl docs
</a>{' '}
handy while you design for now. It's also available in the menu in
the corner of the code pane.
</p>
<p className="my-4">
We've built a{' '}
<a
href="https://github.com/KittyCAD/kcl-lsp"
rel="noreferrer noopener"
target="_blank"
>
language server
</a>{' '}
for kcl that provides documentation and autocompletion automatically
generated from our compiler code. You can try it out by hovering
over some of the function names in the pane now. If you like using
VSCode, you can try out our{' '}
<a
href="https://marketplace.visualstudio.com/items?itemName=KittyCAD.kcl-language-server"
rel="noreferrer noopener"
target="_blank"
>
VSCode extension
</a>
.
</p>
<p className="my-4">
You can resize the pane by dragging the handle on the right, and you
can collapse it by clicking the X button in the pane's title bar or
pressing <kbd className={kbdClasses}>Shift + C</kbd>.
</p>
</section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EDITOR} />
</div>
</div>
)
}

View File

@ -0,0 +1,634 @@
import {
type DesktopOnboardingPath,
desktopOnboardingPaths,
} from '@src/lib/onboardingPaths'
import { useRouteLoaderData, type RouteObject } from 'react-router-dom'
import {
isModelingCmdGroupReady,
OnboardingButtons,
OnboardingCard,
useAdvanceOnboardingOnFormSubmit,
useOnboardingHighlight,
useOnboardingPanes,
useOnModelingCmdGroupReadyOnce,
} from '@src/routes/Onboarding/utils'
import { useEffect, useState } from 'react'
import { commandBarActor, systemIOActor } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { joinRouterPaths, PATHS } from '@src/lib/paths'
import {
ONBOARDING_DATA_ATTRIBUTE,
ONBOARDING_PROJECT_NAME,
} from '@src/lib/constants'
import type { IndexLoaderData } from '@src/lib/types'
import type { Selections } from '@src/lib/selections'
import { Spinner } from '@src/components/Spinner'
import { modifiedFanHousing } from '@src/lib/exampleKcl'
type DesktopOnboardingRoute = RouteObject & {
path: keyof typeof desktopOnboardingPaths
}
/**
* This is the mapping between desktop onboarding paths and the components that will be rendered.
* All components are defined below in this file.
*
* Desktop onboarding content is completely separate from browser onboarding content.
*/
const onboardingComponents: Record<DesktopOnboardingPath, JSX.Element> = {
'/desktop': <Welcome />,
'/desktop/scene': <Scene />,
'/desktop/toolbar': <Toolbar />,
'/desktop/text-to-cad': <TextToCad />,
'/desktop/text-to-cad-prompt': <TextToCadPrompt />,
'/desktop/feature-tree-pane': <FeatureTreePane />,
'/desktop/code-pane': <CodePane />,
'/desktop/project-pane': <ProjectPane />,
'/desktop/other-panes': <OtherPanes />,
'/desktop/prompt-to-edit': <PromptToEdit />,
'/desktop/prompt-to-edit-prompt': <PromptToEditPrompt />,
'/desktop/prompt-to-edit-result': <PromptToEditResult />,
'/desktop/imports': <Imports />,
'/desktop/exports': <Exports />,
'/desktop/conclusion': <OnboardingConclusion />,
}
function Welcome() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop'
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
// Ensure panes are closed
useOnboardingPanes()
// Things that happen when we load this route
useEffect(() => {
// Navigate to the `main.kcl` file
systemIOActor.send({
type: SystemIOMachineEvents.navigateToFile,
data: {
requestedProjectName:
loaderData?.project?.name || ONBOARDING_PROJECT_NAME,
requestedFileName: 'main.kcl',
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [loaderData?.project?.name])
return (
<div className="fixed inset-0 z-50 grid items-end justify-center p-2">
<OnboardingCard>
<h1 className="text-xl font-bold">Welcome to Zoo Design Studio</h1>
<p className="my-4">
Here is an assembly of a CPU fan that was made in Zoo Design Studio.
</p>
<p className="my-4">
Lets walk through the basics of how to get started, and how you can
use several tools at your disposal to create great designs.
</p>
<OnboardingButtons currentSlug="/desktop" platform="desktop" />
</OnboardingCard>
</div>
)
}
function Scene() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/scene'
// Ensure panes are closed
useOnboardingPanes()
// Things that happen when we load this route
useEffect(() => {
// Create if necessary and navigate to the `blank.kcl` file
systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL,
data: {
requestedProjectName: ONBOARDING_PROJECT_NAME,
requestedFileNameWithExtension: 'blank.kcl',
requestedCode: '',
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="pointer-events-none fixed inset-0 z-50 grid items-end justify-center p-2">
<OnboardingCard className="pointer-events-auto">
<h1 className="text-xl font-bold">Scene</h1>
<p className="my-4">
Here is a blank scene. There are three default planes shown when the
scene is empty. Try right-clicking and dragging to orbit around, and
scroll to zoom in and out.
</p>
<OnboardingButtons currentSlug="/desktop/scene" platform="desktop" />
</OnboardingCard>
</div>
)
}
function Toolbar() {
// Highlight the toolbar if it's present
useOnboardingHighlight('toolbar')
// Ensure panes are closed
useOnboardingPanes()
return (
<div className="fixed inset-0 z-[99] grid items-start justify-center p-16">
<OnboardingCard>
<h1 className="text-xl font-bold">This is the toolbar</h1>
<p className="my-4">
You can perform modeling and sketching actions by clicking any of the
tools.
</p>
<OnboardingButtons currentSlug="/desktop/toolbar" platform="desktop" />
</OnboardingCard>
</div>
)
}
function TextToCad() {
// Highlight the text-to-cad button if it's present
useOnboardingHighlight('ai-group')
// Ensure panes are closed
useOnboardingPanes()
return (
<div className="fixed inset-0 z-50 grid items-start justify-center p-16">
<OnboardingCard>
<h1 className="text-xl font-bold">Text-to-CAD</h1>
<p className="my-4">
This last button is Text-to-CAD. This allows you to write up a
description of what you want, and our AI will generate the CAD for
you. Text-to-CAD is currently in an experimental stage. We are
improving it every day.
</p>
<p className="my-4">
<strong>One</strong> Text-to-CAD generation costs{' '}
<strong>one credit per minute</strong>, rounded up to the nearest
minute. A large majority of Text-to-CAD generations take under a
minute. If you are on the free plan, you get 20 free credits per
month. With any of our paid plans, you get unlimited Text-to-CAD
generations.
</p>
<p className="my-4">
Lets walk through an example of how to use Text-to-CAD.
</p>
<OnboardingButtons
currentSlug="/desktop/text-to-cad"
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function TextToCadPrompt() {
const thisOnboardingStatus: DesktopOnboardingPath =
'/desktop/text-to-cad-prompt'
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const prompt =
'Design a fan housing for a CPU cooler for a 120mm diameter fan with four holes for retaining clips'
// Ensure panes are closed
useOnboardingPanes()
// Enter the text-to-cad flow with a prebaked prompt
useEffect(() => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'existingProject',
projectName: loaderData?.project?.name,
prompt,
},
},
})
}, [loaderData?.project?.name])
// Make it so submitting the command just advances the onboarding
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus, 'desktop')
return (
<div className="fixed inset-0 z-[99] grid items-center justify-center">
<OnboardingCard className="pointer-events-auto">
<h1 className="text-xl font-bold">Text-to-CAD prompt</h1>
<p className="my-4">
When you click the Text-to-CAD button, it opens the command palette to
where you can input a text prompt. To save you a Text-to-CAD
generation credit, we are going to use a pre-rolled Text-to-CAD prompt
for this example. Click next to see an example of what Text-to-CAD can
generate.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function FeatureTreePane() {
const thisOnboardingStatus: DesktopOnboardingPath =
'/desktop/feature-tree-pane'
const generatedFileName = 'fan-housing.kcl'
// Highlight the feature tree pane button if it's present
useOnboardingHighlight('feature-tree-pane-button')
// Open the feature tree pane on mount, close on unmount
useOnboardingPanes(['feature-tree'])
// navigate to the "generated" file
useEffect(() => {
systemIOActor.send({
type: SystemIOMachineEvents.navigateToFile,
data: {
requestedProjectName: ONBOARDING_PROJECT_NAME,
requestedFileName: generatedFileName,
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="fixed inset-0 z-[99] p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">CPU Fan Housing</h1>
<p className="my-4">
This is an example of a generated CAD model. We skipped the real
generation for this tutorial, but normally you'll be asked to approve
the generation first.
</p>
<p className="my-4">
To the left are the panes. We have opened the feature tree pane for
you. The feature tree pane displays all the CAD functions that were
performed to create this part. You can double click feature tree items
to edit their parameters.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function CodePane() {
// Highlight the feature tree pane button if it's present
useOnboardingHighlight('code-pane-button')
// Open the code pane on mount, close on unmount
useOnboardingPanes(['code'])
return (
<div className="fixed inset-0 z-50 p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">KCL Code</h1>
<p className="my-4">
This is the KCL Pane. KCL (KittyCAD Language) is a scripting language
we created to describe CAD geometries. This code is the source of
truth, everything you do to the model will change the code.
</p>
<p className="my-4">
KCL boasts other scripting features such as imports, functions and
logic. Not only can you edit your geometry from the feature tree, but
you can also edit the code directly.
</p>
<OnboardingButtons
currentSlug="/desktop/code-pane"
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function ProjectPane() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/project-pane'
// Highlight the feature tree pane button if it's present
useOnboardingHighlight('files-pane-button')
// Open the code pane on mount, close on unmount
useOnboardingPanes(['files'])
return (
<div className="fixed inset-0 z-50 p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Files Pane</h1>
<p className="my-4">
The next pane is the Project Files Pane. Here you can see all of the
files you have in this project. This can be other KCL files as well as
external CAD files (STEP, STL, OBJ, etc.).
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function OtherPanes() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/other-panes'
// Highlight the log and variable panes button if it's present
useOnboardingHighlight('logs-pane-button')
useOnboardingHighlight('variables-pane-button')
// Open the panes on mount, close on unmount
useOnboardingPanes(['logs', 'variables'])
return (
<div className="fixed inset-0 z-50 p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Other panes</h1>
<p className="my-4">
These last two panes are the Variables Pane and Logs Pane. The
Variables pane will display the numeric values of any parameters you
made, along with other entities and types created in your file. The
Logs pane will show error logs.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function PromptToEdit() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/prompt-to-edit'
// Click the text-to-cad dropdown button if it's available
useEffect(() => {
const dropdownButton = document.querySelector(
`[data-${ONBOARDING_DATA_ATTRIBUTE}="ai-dropdown-button"]`
)
if (dropdownButton === null) {
console.error(
`Expected dropdown is not present in onboarding step '${thisOnboardingStatus}'`
)
return
}
if (dropdownButton instanceof HTMLButtonElement) {
dropdownButton.click()
}
}, [])
// Close the panes on mount, close on unmount
useOnboardingPanes()
// Make it so pressing Enter advances instead of toggling the dropdown
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus, 'desktop')
return (
<div className="fixed inset-0 z-50 p-8 grid justify-center items-center">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Modify with Zoo Text-to-CAD</h1>
<p className="my-4">
Text-to-CAD not only can <strong>create</strong> a part, but also{' '}
<strong>modify</strong> an existing part. In the dropdown, youll see
Modify with Zoo Text-to-CAD. Once clicked, youll describe the
change you want for your part, and our AI will generate the change.
Once again, this will cost <strong>one credit per minute</strong> it
took to generate. Once again, most of the time, this is under a
minute.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function PromptToEditPrompt() {
const thisOnboardingStatus: DesktopOnboardingPath =
'/desktop/prompt-to-edit-prompt'
const prompt =
'Change the housing to be for a 150 mm diameter fan, make it 30 mm tall, and change the color to purple.'
// Ensure panes are closed
useOnboardingPanes()
// Enter the prompt-to-edit flow with a prebaked prompt
const [isReady, setIsReady] = useState(
isModelingCmdGroupReady(commandBarActor.getSnapshot())
)
useOnModelingCmdGroupReadyOnce(() => {
setIsReady(true)
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',
name: 'Prompt-to-edit',
argDefaultValues: {
selection: {
graphSelections: [],
otherSelections: [],
} satisfies Selections,
prompt,
},
},
})
}, [])
// Make it so submitting the command just advances the onboarding
useAdvanceOnboardingOnFormSubmit(thisOnboardingStatus, 'desktop')
return (
<div className="fixed inset-0 z-[99] grid items-center justify-center">
<OnboardingCard className="pointer-events-auto">
<h1 className="text-xl font-bold">Modify with Text-to-CAD prompt</h1>
{!isReady && (
<p className="absolute top-0 right-0 m-4 w-fit flex items-center py-1 px-2 rounded bg-chalkboard-20 dark:bg-chalkboard-80">
<Spinner className="w-5 h-5 inline-block mr-2" />
Waiting for connection...
</p>
)}
<p className="my-4">
To save you a credit, we are using a pre-rolled Text-to-CAD prompt to
edit your existing fan housing. You can see the prompt in the window
above. Click next to see an example of what modifying with Text-to-CAD
would look like.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function PromptToEditResult() {
const thisOnboardingStatus: DesktopOnboardingPath =
'/desktop/prompt-to-edit-result'
// Open the code pane on mount, close on unmount
useOnboardingPanes(['code'])
useEffect(() => {
// Navigate to the `main.kcl` file
systemIOActor.send({
type: SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject,
data: {
requestedProjectName: ONBOARDING_PROJECT_NAME,
files: [
{
requestedFileName: 'fan-housing.kcl',
requestedProjectName: ONBOARDING_PROJECT_NAME,
requestedCode: modifiedFanHousing,
},
],
override: true,
requestedSubRoute: joinRouterPaths(
String(PATHS.ONBOARDING),
thisOnboardingStatus
),
},
})
}, [])
return (
<div className="fixed inset-0 z-[99] p-8 grid justify-center items-end">
<OnboardingCard className="col-start-3 col-span-2">
<h1 className="text-xl font-bold">Result</h1>
<p className="my-4">
This is an example of an edit that Text-to-CAD can make for you. We
skipped the real generation for this tutorial, but normally you'll be
asked to approve the generation first.
</p>
<p className="my-4">
Text-to-CAD will make changes across files in your project, so if you
have named parameters in another file that need to change to complete
your request, it is smart enough to go find their source and change
them.
</p>
<p className="my-4">
All of our Text-to-CAD capabilities are experimental, so please report
any issues to us and stay tuned for updates! We are working on it
every day.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function Imports() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/imports'
// Highlight the import and insert buttons if they're present
useOnboardingHighlight('add-file-to-project-pane-button')
useOnboardingHighlight('insert')
// Close the panes on mount, close on unmount
useOnboardingPanes()
return (
<div className="fixed inset-0 z-50 p-16 flex flex-col gap-8 items-center">
<OnboardingCard>
<h1 className="text-xl font-bold">Add file(s) to project</h1>
<p className="my-4">
"Add file(s) to project" is available in the left sidebar. Use it to
bring files into your project, whether from the sample library or from
your local drive.
</p>
<h1 className="text-xl font-bold">Insert parts</h1>
<p className="my-4">
Once a file has been added to your project, you can add it to the
scene using insert. Insert is available in the toolbar. This is the
first step to making assemblies!
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
function Exports() {
const thisOnboardingStatus: DesktopOnboardingPath = '/desktop/exports'
// Highlight the export button if it's present
useOnboardingHighlight('export-pane-button')
// Close the panes on mount, close on unmount
useOnboardingPanes()
return (
<div className="fixed inset-0 z-50 p-16 grid justify-start items-center">
<OnboardingCard>
<h1 className="text-xl font-bold">Exporting</h1>
<p className="my-4">
You can export the currently-opened part by clicking the Export button
in the left sidebar. We support exporting to STEP, gLTF, STL, OBJ, and
more.
</p>
<OnboardingButtons
currentSlug={thisOnboardingStatus}
platform="desktop"
dismissPosition="right"
/>
</OnboardingCard>
</div>
)
}
function OnboardingConclusion() {
// Highlight the App logo
useOnboardingHighlight('app-logo')
// Close the panes on mount, close on unmount
useOnboardingPanes(
['feature-tree', 'code', 'files'],
['feature-tree', 'code', 'files']
)
return (
<div className="fixed inset-0 z-50 p-16 grid justify-center items-center">
<OnboardingCard>
<h1 className="text-xl font-bold">Time to start building</h1>
<p className="my-4">
We appreciate you downloading Zoo Design Studio and taking the time to
walk through the basics. To navigate back home to create your own
project, click the Zoo button in the top left (gesture). To learn more
detailed and advanced techniques, go here (TODO tutorials).
</p>
<OnboardingButtons
currentSlug="/desktop/conclusion"
platform="desktop"
/>
</OnboardingCard>
</div>
)
}
export const desktopOnboardingRoutes: DesktopOnboardingRoute[] = [
...Object.values(desktopOnboardingPaths).map((path) => ({
path,
index: true,
element: onboardingComponents[path],
})),
]

View File

@ -1,56 +0,0 @@
import { APP_NAME } from '@src/lib/constants'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Export() {
return (
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-full xl:max-w-2xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1">
<h2 className="text-2xl font-bold">Export</h2>
<p className="my-4">
In addition to the "Export current part" button in the project menu,
you can also click the Export button icon at the bottom of the left
sidebar. Try clicking it now.
</p>
<p className="my-4">
{APP_NAME} uses{' '}
<a
href="https://zoo.dev/gltf-format-extension"
rel="noopener noreferrer"
target="_blank"
>
our open-source extension proposal
</a>{' '}
for the glTF file format.{' '}
<a
href="https://zoo.dev/docs/api/convert-cad-file"
rel="noopener noreferrer"
target="_blank"
>
Our conversion API
</a>{' '}
can convert to and from most common CAD file formats, allowing
export to almost any CAD software.
</p>
<p className="my-4">
Our teammate Katie is working on the file format, check out{' '}
<a
href="https://github.com/KhronosGroup/glTF/pull/2343"
target="_blank"
rel="noreferrer noopener"
>
her standards proposal on GitHub
</a>
!
</p>
</section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.EXPORT} />
</div>
</div>
)
}

View File

@ -1,65 +0,0 @@
import { useEffect } from 'react'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { APP_NAME } from '@src/lib/constants'
import { sceneInfra } from '@src/lib/singletons'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function FutureWork() {
const { send } = useModelingContext()
// Reset the code, the camera, and the modeling state
useDemoCode()
useEffect(() => {
send({ type: 'Cancel' }) // in case the user hit 'Next' while still in sketch mode
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sceneInfra.camControls.resetCameraPosition()
}, [send])
return (
<div className="fixed grid justify-center items-center inset-0 bg-chalkboard-100/50 z-50">
<div className="relative max-w-full xl:max-w-2xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded">
<h1 className="text-2xl font-bold">Future Work</h1>
<p className="my-4">
We have curves, cuts, multi-profile sketch mode, and many more CAD
features coming soon. We want your feedback on this user interface,
and we want to know what features you want to see next. Please message
us in{' '}
<a
href="https://discord.gg/JQEpHR7Nt2"
target="_blank"
rel="noreferrer noopener"
>
our Discord server
</a>
and{' '}
<a
href="https://github.com/KittyCAD/modeling-app/issues/new/choose"
rel="noreferrer noopener"
target="_blank"
>
open issues on GitHub
</a>
.
</p>
<p className="my-4">
If you make anything with the app we'd love to see it, feel free to{' '}
<a
href="https://twitter.com/zoodotdev"
target="_blank"
rel="noreferrer noopener"
>
tag us on X
</a>
! Thank you for taking time to try out {APP_NAME}, and build the
future of hardware design with us.
</p>
<p className="my-4">💚 The Zoo Team</p>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.FUTURE_WORK}
className="mt-6"
/>
</div>
</div>
)
}

View File

@ -1,93 +0,0 @@
import { bracketWidthConstantLine } from '@src/lib/exampleKcl'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import {
OnboardingButtons,
kbdClasses,
useDemoCode,
} from '@src/routes/Onboarding/utils'
export default function OnboardingInteractiveNumbers() {
useDemoCode()
return (
<div className="fixed grid justify-end items-center inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto z-10 max-w-xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg h-[75vh] flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1 overflow-y-auto mb-6">
<h2 className="text-3xl font-bold">Hybrid editing</h2>
<p className="my-4">
We believe editing in Design Studio should feel fluid between code
and point-and-click, so that you can work in the way that feels most
natural to you. Let's try something out that demonstrates this
principle, by editing numbers without typing.
</p>
<ol className="pl-6 my-4 list-decimal">
<li className="list-decimal">
Press and hold the <kbd className={kbdClasses}>Alt</kbd> (or{' '}
<kbd className={kbdClasses}>Option</kbd>) key
</li>
<li>
Hover over the number assigned to "width" on{' '}
<em>
<strong>line {bracketWidthConstantLine}</strong>
</em>
</li>
<li>Drag the number left and right to change its value</li>
</ol>
<p className="my-4">
You can hold down different modifier keys to change the value by
different increments:
</p>
<ul className="flex flex-col text-sm my-4 mx-12 divide-y divide-chalkboard-20 dark:divide-chalkboard-70">
<li className="flex justify-between m-0 px-0 py-2">
<kbd className={kbdClasses}>Alt + Shift + Cmd/Win</kbd>
±0.01
</li>
<li className="flex justify-between m-0 px-0 py-2">
<kbd className={kbdClasses}>Alt + Cmd/Win</kbd>
±0.1
</li>
<li className="flex justify-between m-0 px-0 py-2">
<kbd className={kbdClasses}>Alt</kbd>±1
</li>
<li className="flex justify-between m-0 px-0 py-2">
<kbd className={kbdClasses}>Alt + Shift</kbd>
±10
</li>
</ul>
<p className="my-4">
Our code editor is built with{' '}
<a
href="https://codemirror.net/"
target="_blank"
rel="noreferrer noopeneer"
>
CodeMirror
</a>
, a great open-source project with extensions that make it even more
dynamic and interactive, including{' '}
<a
href="https://github.com/replit/codemirror-interact/"
target="_blank"
rel="noreferrer noopeneer"
>
one by the Replit team
</a>{' '}
lets you interact with numbers in your code by dragging them around.
</p>
<p className="my-4">
We're going to keep extending the text editor, and we'd love to hear
your ideas for how to make it better.
</p>
</section>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS}
/>
</div>
</div>
)
}

View File

@ -1,78 +0,0 @@
import { APP_NAME } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/lib/singletons'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Introduction() {
// Reset the code to the bracket code
useDemoCode()
const {
app: { theme },
} = useSettings()
const getLogoTheme = () =>
theme.current === Themes.Light ||
(theme.current === Themes.System && getSystemTheme() === Themes.Light)
? '-dark'
: ''
return (
<div className="fixed inset-0 z-50 grid place-content-center bg-chalkboard-110/50">
<div className="relative max-w-3xl p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
<h1 className="flex flex-wrap items-center gap-4 text-3xl font-bold">
<img
src={`${isDesktop() ? '.' : ''}/zma-logomark${getLogoTheme()}.svg`}
alt={APP_NAME}
className="h-20 max-w-full"
/>
<span className="px-3 py-1 text-base rounded-full bg-primary/10 text-primary">
Alpha
</span>
</h1>
<section className="my-12">
<p className="my-4">
Welcome to {APP_NAME}! This is a hardware design tool that lets you
edit visually, with code, or both. It's powered by the KittyCAD
Design API, the first API created for anyone to build hardware
design tools. The 3D view is not running on your computer, but is
instead being streamed to you from an instance of our Geometry
Engine on a remote GPU as video.
</p>
<p className="my-4">
This is an alpha release, so you will encounter bugs and missing
features. You can read our{' '}
<a
href="https://gist.github.com/jgomez720/5cd53fb7e8e54079f6dc0d2625de5393"
target="_blank"
rel="noreferrer noopener"
>
expectations for alpha users here
</a>
, and please give us feedback on your experience{' '}
<a
href="https://discord.com/invite/JQEpHR7Nt2"
target="_blank"
rel="noreferrer noopener"
>
our Discord
</a>
! We are trying to release as early as possible to get feedback from
users like you.
</p>
<p>
As you go through the onboarding, we'll be changing and resetting
your code occasionally, so that we can reference specific code
features. So hold off on writing production KCL code until you're
done with the onboarding 😉
</p>
</section>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.INDEX}
className="mt-6"
/>
</div>
</div>
)
}

View File

@ -1,80 +0,0 @@
import { bracketThicknessCalculationLine } from '@src/lib/exampleKcl'
import { isDesktop } from '@src/lib/isDesktop'
import { Themes, getSystemTheme } from '@src/lib/theme'
import { useSettings } from '@src/lib/singletons'
import { OnboardingButtons, useDemoCode } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function OnboardingParametricModeling() {
useDemoCode()
const {
app: {
theme: { current: theme },
},
} = useSettings()
const getImageTheme = () =>
theme === Themes.Light ||
(theme === Themes.System && getSystemTheme() === Themes.Light)
? '-dark'
: ''
return (
<div className="fixed grid justify-end items-center inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto z-10 max-w-xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg h-[75vh] flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1 overflow-y-auto mb-6">
<h2 className="text-3xl font-bold">Parametric modeling with kcl</h2>
<p className="my-4">
This example script shows how a code representation of your design
makes easy work of tedious tasks in traditional CAD software, such
as calculating a safety factor.
</p>
<p className="my-4">
We've received this sketch from a designer highlighting an{' '}
<em>
<strong>aluminum bracket</strong>
</em>{' '}
they need for this shelf:
</p>
<figure className="my-4 w-2/3 mx-auto">
<img
src={`${
isDesktop() ? '.' : ''
}/onboarding-bracket${getImageTheme()}.png`}
alt="Bracket"
/>
<figcaption className="text-small italic text-center">
A simplified shelf bracket
</figcaption>
</figure>
<p className="my-4">
We are able to easily calculate the thickness of the material based
on the width of the bracket to meet a set safety factor on{' '}
<em>
<strong>line {bracketThicknessCalculationLine}</strong>
</em>
.
</p>
<figure className="my-4 w-2/3 mx-auto">
<img
src={`${
isDesktop() ? '.' : ''
}/onboarding-bracket-dimensions${getImageTheme()}.png`}
alt="Bracket Dimensions"
/>
<figcaption className="text-small italic text-center">
Bracket Dimensions
</figcaption>
</figure>
</section>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.PARAMETRIC_MODELING}
/>
</div>
</div>
)
}

View File

@ -1,62 +0,0 @@
import { isDesktop } from '@src/lib/isDesktop'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function ProjectMenu() {
const onDesktop = isDesktop()
return (
<div className="fixed grid justify-center items-start inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-xl flex flex-col border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1">
<h2 className="text-2xl font-bold">Project Menu</h2>
<p className="my-4">
Click on {onDesktop ? `your part's name` : `the app name`} in the
upper left to open the project menu, where you can open the project
settings and export your current part.
{onDesktop && (
<> You can click the Zoo logo to quickly navigate home.</>
)}
</p>
{onDesktop ? (
<>
<p className="my-4">
From here you can manage files in your project and export your
current part. Your projects are{' '}
<strong>all saved locally</strong> as a folder on your device.
You can configure where projects are saved in the settings.
</p>
<p className="my-4">
We are working to support assemblies as separate kcl files
importing parts from each other, but for now you can only open
and export individual parts.
</p>
</>
) : (
<>
<p className="my-4">
You can't manage separate files and separate projects from the
browser; you have to{' '}
<a
href="https://zoo.dev/modeling-app/download"
target="_blank"
rel="noreferrer noopener"
>
download the desktop app
</a>{' '}
for that. We aren't hosting files for you at this time but are
considering supporting it in the future, so we're building
Design Studio with a browser-first experience in mind.
</p>
</>
)}
</section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.PROJECT_MENU} />
</div>
</div>
)
}

View File

@ -1,49 +0,0 @@
import { useEffect } from 'react'
import { codeManager, kclManager } from '@src/lib/singletons'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Sketching() {
useEffect(() => {
async function clearEditor() {
// We do want to update both the state and editor here.
codeManager.updateCodeStateEditor('')
await kclManager.executeCode()
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
clearEditor()
}, [])
return (
<div className="fixed grid justify-center items-end inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-full xl:max-w-2xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<h1 className="text-2xl font-bold">Sketching</h1>
<p className="my-4">
Our 3D modeling tools are still very much a work in progress, but we
want to show you some early features. Try sketching by clicking Start
Sketch in the top toolbar and selecting a plane to draw on. Now you
can start clicking to draw lines and shapes.
</p>
<p className="my-4">
The Line tool will be equipped by default, but you can switch it to as
you go by clicking another tool in the toolbar, or unequip it by
clicking the Line tool button. With no tool selected, you can move
points and add constraints to your sketch.
</p>
<p className="my-4">
Watch the code pane as you click. Point-and-click interactions are
always just modifying and generating code in Zoo Design Studio.
</p>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.SKETCHING}
className="mt-6"
/>
</div>
</div>
)
}

View File

@ -1,49 +0,0 @@
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
export default function Streaming() {
return (
<div className="fixed grid justify-start items-center inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-xl border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg h-[75vh] flex flex-col justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1 overflow-y-auto">
<h2 className="text-3xl font-bold">Streaming Video</h2>
<p className="my-4">
Historically, CAD programs run on your computer, so to run
performance-heavy apps you have to have a powerful, expensive
desktop. But the 3D scene you see here is not running on your
computer.
</p>
<p className="my-4">
Instead, our infrastructure spins up our Geometry Engine on a remote
GPU, Design Studio sends it a series of commands{' '}
<a
href="https://zoo.dev/blog/cad-webrtc"
rel="noopener noreferrer"
target="_blank"
>
via Websockets and WebRTC
</a>
, and the Geometry Engine sends back a video stream of the 3D view.
</p>
<p className="my-4">
This means that you could run our Design Studio on nearly any device
with a good internet connection.
</p>
<p className="my-4">
It also means that whatever tools you build on top of our Geometry
Engine will be able to run on any device with a browser, and you
won't have to worry about the performance of the device.
</p>
</section>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.STREAMING}
dismissClassName="right-auto left-full"
/>
</div>
</div>
)
}

View File

@ -1,48 +0,0 @@
import { SettingsSection } from '@src/components/Settings/SettingsSection'
import { type BaseUnit, baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { settingsActor, useSettings } from '@src/lib/singletons'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function Units() {
const {
modeling: { defaultUnit },
} = useSettings()
return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
<div className="max-w-3xl bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded">
<h1 className="text-2xl font-bold">Set your units</h1>
<SettingsSection
title="Default Unit"
description="Which unit to use in modeling dimensions by default"
>
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultUnit.user}
onChange={(e) => {
settingsActor.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'user',
value: e.target.value as BaseUnit,
},
})
}}
>
{baseUnitsUnion.map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
))}
</select>
</SettingsSection>
<OnboardingButtons
currentSlug={ONBOARDING_SUBPATHS.UNITS}
className="mt-6"
/>
</div>
</div>
)
}

View File

@ -1,53 +0,0 @@
import { useEffect, useState } from 'react'
import { useUser } from '@src/lib/singletons'
import { OnboardingButtons } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths'
export default function UserMenu() {
const user = useUser()
const [avatarErrored, setAvatarErrored] = useState(false)
const errorOrNoImage = !user?.image || avatarErrored
const buttonDescription = errorOrNoImage ? 'the menu button' : 'your avatar'
// Set up error handling for the user's avatar image,
// so the onboarding text can be updated if it fails to load.
useEffect(() => {
const element = globalThis.document.querySelector(
'[data-testid="user-sidebar-toggle"] img'
)
const onError = () => setAvatarErrored(true)
if (element?.tagName === 'IMG') {
element?.addEventListener('error', onError)
}
return () => {
element?.removeEventListener('error', onError)
}
}, [])
return (
<div className="fixed grid justify-center items-start inset-0 z-50 pointer-events-none">
<div
className={
'relative pointer-events-auto max-w-xl flex flex-col border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg justify-center bg-chalkboard-10 dark:bg-chalkboard-90 p-8 rounded'
}
>
<section className="flex-1">
<h2 className="text-2xl font-bold">User Menu</h2>
<p className="my-4">
Click {buttonDescription} in the upper right to open the user menu.
You can change your user-level settings, sign out, report a bug,
manage your account, request a feature, and more.
</p>
<p className="my-4">
Many settings can be set either a user or per-project level. User
settings will apply to all projects, while project settings will
only apply to the current project.
</p>
</section>
<OnboardingButtons currentSlug={ONBOARDING_SUBPATHS.USER_MENU} />
</div>
</div>
)
}

View File

@ -1,83 +1,30 @@
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import Camera from '@src/routes/Onboarding/Camera'
import CmdK from '@src/routes/Onboarding/CmdK'
import CodeEditor from '@src/routes/Onboarding/CodeEditor'
import Export from '@src/routes/Onboarding/Export'
import FutureWork from '@src/routes/Onboarding/FutureWork'
import InteractiveNumbers from '@src/routes/Onboarding/InteractiveNumbers'
import Introduction from '@src/routes/Onboarding/Introduction'
import ParametricModeling from '@src/routes/Onboarding/ParametricModeling'
import ProjectMenu from '@src/routes/Onboarding/ProjectMenu'
import Sketching from '@src/routes/Onboarding/Sketching'
import Streaming from '@src/routes/Onboarding/Streaming'
import UserMenu from '@src/routes/Onboarding/UserMenu'
import { useDismiss } from '@src/routes/Onboarding/utils' import { useDismiss } from '@src/routes/Onboarding/utils'
import { ONBOARDING_SUBPATHS } from '@src/lib/onboardingPaths' import { browserOnboardingRoutes } from '@src/routes/Onboarding/BrowserOnboardingRoutes'
import { desktopOnboardingRoutes } from '@src/routes/Onboarding/DesktopOnboardingRoutes'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
/** Compile the onboarding routes into one object
* for use in the Router.
*/
export const onboardingRoutes = [ export const onboardingRoutes = [
{ ...browserOnboardingRoutes,
index: true, ...desktopOnboardingRoutes,
element: <Introduction />, ].map(({ path, ...route }) => ({
}, // react-router-dom wants these path to be relative in Router.tsx
{ path: makeUrlPathRelative(path),
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.CAMERA), ...route,
element: <Camera />, }))
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.STREAMING),
element: <Streaming />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EDITOR),
element: <CodeEditor />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PARAMETRIC_MODELING),
element: <ParametricModeling />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.INTERACTIVE_NUMBERS),
element: <InteractiveNumbers />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.COMMAND_K),
element: <CmdK />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.USER_MENU),
element: <UserMenu />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.PROJECT_MENU),
element: <ProjectMenu />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.EXPORT),
element: <Export />,
},
// Export / conversion API
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.SKETCHING),
element: <Sketching />,
},
{
path: makeUrlPathRelative(ONBOARDING_SUBPATHS.FUTURE_WORK),
element: <FutureWork />,
},
]
const Onboarding = () => { export const OnboardingRootRoute = () => {
const dismiss = useDismiss() const dismiss = useDismiss()
useHotkeys('esc', () => dismiss()) useHotkeys('esc', () => dismiss())
return ( return (
<div className="content" data-testid="onboarding-content"> <div className="content" data-testid="onboarding-content">
{/* Outlet is a magic react-router-dom element that hot-swaps child route content */}
<Outlet /> <Outlet />
</div> </div>
) )
} }
export default Onboarding

View File

@ -0,0 +1,109 @@
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import type { OnboardingPath } from '@src/lib/onboardingPaths'
import {
needsToOnboard,
useAdjacentOnboardingSteps,
} from '@src/routes/Onboarding/utils'
import type { Location } from 'react-router-dom'
describe('Onboarding utility functions', () => {
describe('useAdjacentOnboardingSteps', () => {
it('Desktop beginning', () => {
const stepResults = useAdjacentOnboardingSteps('/desktop', 'desktop')
const expected: OnboardingStatus[] = ['dismissed', '/desktop/scene']
expect(stepResults).toEqual(expected)
})
it('Desktop middle', () => {
const stepResults = useAdjacentOnboardingSteps(
'/desktop/other-panes',
'desktop'
)
const expected: OnboardingStatus[] = [
'/desktop/project-pane',
'/desktop/prompt-to-edit',
]
expect(stepResults).toEqual(expected)
})
it('Desktop end', () => {
const stepResults = useAdjacentOnboardingSteps(
'/desktop/conclusion',
'desktop'
)
const expected: OnboardingStatus[] = ['/desktop/exports', 'completed']
expect(stepResults).toEqual(expected)
})
it('Browser beginning', () => {
const stepResults = useAdjacentOnboardingSteps('/browser', 'browser')
const expected: OnboardingStatus[] = ['dismissed', '/browser/scene']
expect(stepResults).toEqual(expected)
})
it('Browser middle', () => {
const stepResults = useAdjacentOnboardingSteps(
'/browser/text-to-cad-prompt',
'browser'
)
const expected: OnboardingStatus[] = [
'/browser/text-to-cad',
'/browser/feature-tree-pane',
]
expect(stepResults).toEqual(expected)
})
it('Browser end', () => {
const stepResults = useAdjacentOnboardingSteps(
'/browser/conclusion',
'browser'
)
const expected: OnboardingStatus[] = [
'/browser/prompt-to-edit-result',
'completed',
]
expect(stepResults).toEqual(expected)
})
it('Errors gracefully', () => {
const stepResults = useAdjacentOnboardingSteps(
'/bad-path' as unknown as OnboardingPath,
'desktop'
)
const expected: OnboardingStatus[] = ['dismissed', 'completed']
expect(stepResults).toEqual(expected)
})
})
describe('needsToOnboard', () => {
it('in onboarding already does not need onboarding', () => {
const location: Location = {
pathname: '/some-file/onboarding/some-step',
search: '',
hash: '',
state: null,
key: 'default',
}
expect(needsToOnboard(location, '')).toEqual(false)
})
it('elsewhere with bad status does need onboarding', () => {
const location: Location = {
pathname: '/somewhere-else',
search: '',
hash: '',
state: null,
key: 'default',
}
expect(
needsToOnboard(
location,
'/bad-onboarding-status' as unknown as OnboardingStatus
)
).toEqual(true)
})
it('elsewhere with completed does not need onboarding', () => {
const location: Location = {
pathname: '/somewhere-else',
search: '',
hash: '',
state: null,
key: 'default',
}
expect(needsToOnboard(location, 'completed')).toEqual(false)
})
})
})

View File

@ -1,34 +1,24 @@
import { useCallback, useEffect } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import {
type NavigateFunction, type NavigateFunction,
type useLocation, type useLocation,
useNavigate, useNavigate,
} from 'react-router-dom' } from 'react-router-dom'
import { waitFor } from 'xstate' import { type SnapshotFrom, waitFor } from 'xstate'
import { ActionButton } from '@src/components/ActionButton' import { ActionButton } from '@src/components/ActionButton'
import { CustomIcon } from '@src/components/CustomIcon' import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip' import Tooltip from '@src/components/Tooltip'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { useNetworkContext } from '@src/hooks/useNetworkContext' import { browserAxialFan, fanParts } from '@src/lib/exampleKcl'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import { bracket } from '@src/lib/exampleKcl'
import makeUrlPathRelative from '@src/lib/makeUrlPathRelative' import makeUrlPathRelative from '@src/lib/makeUrlPathRelative'
import { joinRouterPaths, PATHS } from '@src/lib/paths' import { joinRouterPaths, PATHS } from '@src/lib/paths'
import { import { commandBarActor, systemIOActor } from '@src/lib/singletons'
codeManager, import { err, reportRejection } from '@src/lib/trap'
editorManager,
kclManager,
systemIOActor,
} from '@src/lib/singletons'
import { err, reportRejection, trap } from '@src/lib/trap'
import { settingsActor } from '@src/lib/singletons' import { settingsActor } from '@src/lib/singletons'
import { isKclEmptyOrOnlySettings, parse, resultIsOk } from '@src/lang/wasm' import { isKclEmptyOrOnlySettings } from '@src/lang/wasm'
import { updateModelingState } from '@src/lang/modelingWorkflows'
import { import {
DEFAULT_PROJECT_KCL_FILE, ONBOARDING_DATA_ATTRIBUTE,
EXECUTION_TYPE_REAL,
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
} from '@src/lib/constants' } from '@src/lib/constants'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -39,59 +29,44 @@ import type { KclManager } from '@src/lang/KclSingleton'
import { Logo } from '@src/components/Logo' import { Logo } from '@src/components/Logo'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils' import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { import {
isOnboardingSubPath, isOnboardingPath,
ONBOARDING_SUBPATHS, type OnboardingPath,
onboardingPaths,
onboardingStartPath,
} from '@src/lib/onboardingPaths' } from '@src/lib/onboardingPaths'
import { useModelingContext } from '@src/hooks/useModelingContext'
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
export const kbdClasses = export const kbdClasses =
'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2' 'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2'
// Get the 1-indexed step number of the current onboarding step // Get the 1-indexed step number of the current onboarding step
function useStepNumber( function getStepNumber(
slug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS] slug?: OnboardingPath,
platform: keyof typeof onboardingPaths = 'browser'
) { ) {
return slug ? Object.values(ONBOARDING_SUBPATHS).indexOf(slug) + 1 : -1 return slug ? Object.values(onboardingPaths[platform]).indexOf(slug) + 1 : -1
} }
export function useDemoCode() { export const OnboardingCard = ({
const { overallState, immediateState } = useNetworkContext() className,
children,
useEffect(() => { ...props
async function setCodeToDemoIfNeeded() { }: React.HTMLAttributes<HTMLDivElement>) => (
// Don't run if the editor isn't loaded or the code is already the bracket <div
if (!editorManager.editorView || codeManager.code === bracket) { className={`relative max-w-3xl min-w-80 bg-chalkboard-10 dark:bg-chalkboard-90 py-6 px-8 rounded border border-chalkboard-50 dark:border-chalkboard-80 shadow-lg ${className || ''}`}
return {...props}
} >
// Don't run if the network isn't healthy or the connection isn't established {children}
if ( </div>
overallState === NetworkHealthState.Disconnected || )
overallState === NetworkHealthState.Issue ||
immediateState.type !== EngineConnectionStateType.ConnectionEstablished
) {
return
}
const pResult = parse(bracket)
if (trap(pResult) || !resultIsOk(pResult)) {
return Promise.reject(pResult)
}
const ast = pResult.program
await updateModelingState(ast, EXECUTION_TYPE_REAL, {
kclManager: kclManager,
editorManager: editorManager,
codeManager: codeManager,
})
}
setCodeToDemoIfNeeded().catch(reportRejection)
}, [editorManager.editorView, immediateState.type, overallState])
}
export function useNextClick(newStatus: OnboardingStatus) { export function useNextClick(newStatus: OnboardingStatus) {
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const navigate = useNavigate() const navigate = useNavigate()
return useCallback(() => { return useCallback(() => {
if (!isOnboardingSubPath(newStatus)) { if (!isOnboardingPath(newStatus)) {
return new Error( return new Error(
`Failed to navigate to invalid onboarding status ${newStatus}` `Failed to navigate to invalid onboarding status ${newStatus}`
) )
@ -100,7 +75,8 @@ export function useNextClick(newStatus: OnboardingStatus) {
type: 'set.app.onboardingStatus', type: 'set.app.onboardingStatus',
data: { level: 'user', value: newStatus }, data: { level: 'user', value: newStatus },
}) })
navigate(joinRouterPaths(filePath, PATHS.ONBOARDING.INDEX, newStatus)) const targetRoute = joinRouterPaths(filePath, PATHS.ONBOARDING, newStatus)
navigate(targetRoute)
}, [filePath, newStatus, navigate]) }, [filePath, newStatus, navigate])
} }
@ -137,43 +113,74 @@ export function useDismiss() {
return settingsCallback return settingsCallback
} }
export function OnboardingButtons({ export function useAdjacentOnboardingSteps(
currentSlug, currentSlug?: OnboardingPath,
className, platform: undefined | keyof typeof onboardingPaths = 'browser'
dismissClassName, ) {
onNextOverride, const onboardingPathsArray = Object.values(onboardingPaths[platform])
...props const stepNumber = getStepNumber(currentSlug, platform)
}: {
currentSlug?: (typeof ONBOARDING_SUBPATHS)[keyof typeof ONBOARDING_SUBPATHS]
className?: string
dismissClassName?: string
onNextOverride?: () => void
} & React.HTMLAttributes<HTMLDivElement>) {
const onboardingPathsArray = Object.values(ONBOARDING_SUBPATHS)
const dismiss = useDismiss()
const stepNumber = useStepNumber(currentSlug)
const previousStep = const previousStep =
!stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber] !stepNumber || stepNumber <= 1 ? null : onboardingPathsArray[stepNumber - 2]
const nextStep = const nextStep =
!stepNumber || stepNumber === onboardingPathsArray.length !stepNumber || stepNumber === onboardingPathsArray.length
? null ? null
: onboardingPathsArray[stepNumber] : onboardingPathsArray[stepNumber]
const previousOnboardingStatus: OnboardingStatus = const previousOnboardingStatus: OnboardingStatus = previousStep ?? 'dismissed'
previousStep ?? ONBOARDING_SUBPATHS.INDEX
const nextOnboardingStatus: OnboardingStatus = nextStep ?? 'completed' const nextOnboardingStatus: OnboardingStatus = nextStep ?? 'completed'
return [previousOnboardingStatus, nextOnboardingStatus]
}
export function useOnboardingClicks(
currentSlug?: OnboardingPath,
platform: undefined | keyof typeof onboardingPaths = 'browser'
) {
const [previousOnboardingStatus, nextOnboardingStatus] =
useAdjacentOnboardingSteps(currentSlug, platform)
const goToPrevious = useNextClick(previousOnboardingStatus) const goToPrevious = useNextClick(previousOnboardingStatus)
const goToNext = useNextClick(nextOnboardingStatus) const goToNext = useNextClick(nextOnboardingStatus)
return [goToPrevious, goToNext]
}
export function OnboardingButtons({
currentSlug,
platform = 'browser',
dismissPosition = 'left',
className,
dismissClassName,
onNextOverride,
...props
}: {
currentSlug?: OnboardingPath
platform?: keyof typeof onboardingPaths
dismissPosition?: 'left' | 'right'
className?: string
dismissClassName?: string
onNextOverride?: () => void
} & React.HTMLAttributes<HTMLDivElement>) {
const dismiss = useDismiss()
const onboardingPathsArray = Object.values(onboardingPaths[platform])
const stepNumber = getStepNumber(currentSlug, platform)
const [previousStep, nextStep] = useAdjacentOnboardingSteps(
currentSlug,
platform
)
const [goToPrevious, goToNext] = useOnboardingClicks(currentSlug, platform)
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => dismiss()} onClick={() => dismiss()}
className={`group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ${ className={`group block !absolute top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent ${
dismissClassName dismissClassName
}`} }`}
style={{
left: dismissPosition === 'left' ? 'auto' : '100%',
right: dismissPosition === 'left' ? '100%' : 'auto',
}}
data-testid="onboarding-dismiss" data-testid="onboarding-dismiss"
> >
<CustomIcon <CustomIcon
@ -190,16 +197,25 @@ export function OnboardingButtons({
> >
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => (previousStep ? goToPrevious() : dismiss())} onClick={() =>
previousStep && previousStep !== 'dismissed'
? goToPrevious()
: dismiss()
}
iconStart={{ iconStart={{
icon: previousStep ? 'arrowLeft' : 'close', icon:
previousStep && previousStep !== 'dismissed'
? 'arrowLeft'
: 'close',
className: 'text-chalkboard-10', className: 'text-chalkboard-10',
bgClassName: 'bg-destroy-80 group-hover:bg-destroy-80', bgClassName: 'bg-destroy-80 group-hover:bg-destroy-80',
}} }}
className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50" className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50"
data-testid="onboarding-prev" data-testid="onboarding-prev"
id="onboarding-prev"
tabIndex={0}
> >
{previousStep ? 'Back' : 'Dismiss'} {previousStep && previousStep !== 'dismissed' ? 'Back' : 'Dismiss'}
</ActionButton> </ActionButton>
{stepNumber !== undefined && ( {stepNumber !== undefined && (
<p className="font-mono text-xs text-center m-0"> <p className="font-mono text-xs text-center m-0">
@ -208,9 +224,10 @@ export function OnboardingButtons({
)} )}
<ActionButton <ActionButton
autoFocus autoFocus
tabIndex={0}
Element="button" Element="button"
onClick={() => { onClick={() => {
if (nextStep) { if (nextStep && nextStep !== 'completed') {
const result = onNextOverride ? onNextOverride() : goToNext() const result = onNextOverride ? onNextOverride() : goToNext()
if (err(result)) { if (err(result)) {
reportRejection(result) reportRejection(result)
@ -220,13 +237,15 @@ export function OnboardingButtons({
} }
}} }}
iconStart={{ iconStart={{
icon: nextStep ? 'arrowRight' : 'checkmark', icon:
nextStep && nextStep !== 'completed' ? 'arrowRight' : 'checkmark',
bgClassName: 'dark:bg-chalkboard-80', bgClassName: 'dark:bg-chalkboard-80',
}} }}
className="dark:hover:bg-chalkboard-80/50" className="dark:hover:bg-chalkboard-80/50"
data-testid="onboarding-next" data-testid="onboarding-next"
id="onboarding-next"
> >
{nextStep ? 'Next' : 'Finish'} {nextStep && nextStep !== 'completed' ? 'Next' : 'Finish'}
</ActionButton> </ActionButton>
</div> </div>
</> </>
@ -247,20 +266,28 @@ export const ERROR_MUST_WARN = 'Must warn user before overwrite'
* depending on the platform and the state of the user's code. * depending on the platform and the state of the user's code.
*/ */
export async function acceptOnboarding(deps: OnboardingUtilDeps) { export async function acceptOnboarding(deps: OnboardingUtilDeps) {
// Non-path statuses should be coerced to the start path
const onboardingStatus = !isOnboardingPath(deps.onboardingStatus)
? onboardingStartPath
: deps.onboardingStatus
if (isDesktop()) { if (isDesktop()) {
/** TODO: rename this event to be more generic, like `createKCLFileAndNavigate` */ /**
* Bulk create the assembly and navigate to the project
*/
systemIOActor.send({ systemIOActor.send({
type: SystemIOMachineEvents.importFileFromURL, type: SystemIOMachineEvents.bulkCreateKCLFilesAndNavigateToProject,
data: { data: {
files: fanParts.map((part) => ({
requestedProjectName: ONBOARDING_PROJECT_NAME,
...part,
})),
// Make a unique tutorial project each time
override: true,
requestedProjectName: ONBOARDING_PROJECT_NAME, requestedProjectName: ONBOARDING_PROJECT_NAME,
requestedFileNameWithExtension: DEFAULT_PROJECT_KCL_FILE, requestedSubRoute: joinRouterPaths(PATHS.ONBOARDING, onboardingStatus),
requestedCode: bracket,
requestedSubRoute: joinRouterPaths(
PATHS.ONBOARDING.INDEX,
deps.onboardingStatus
),
}, },
}) })
return Promise.resolve() return Promise.resolve()
} }
@ -282,20 +309,25 @@ export async function resetCodeAndAdvanceOnboarding({
kclManager, kclManager,
navigate, navigate,
}: OnboardingUtilDeps) { }: OnboardingUtilDeps) {
// Non-path statuses should be coerced to the start path
const resolvedOnboardingStatus = !isOnboardingPath(onboardingStatus)
? onboardingStartPath
: onboardingStatus
// We do want to update both the state and editor here. // We do want to update both the state and editor here.
codeManager.updateCodeStateEditor(bracket) codeManager.updateCodeStateEditor(browserAxialFan)
codeManager.writeToFile().catch(reportRejection) codeManager.writeToFile().catch(reportRejection)
kclManager.executeCode().catch(reportRejection) kclManager.executeCode().catch(reportRejection)
navigate( navigate(
makeUrlPathRelative( makeUrlPathRelative(
joinRouterPaths(PATHS.ONBOARDING.INDEX, onboardingStatus) joinRouterPaths(String(PATHS.ONBOARDING), resolvedOnboardingStatus)
) )
) )
} }
function hasResetReadyCode(codeManager: CodeManager) { function hasResetReadyCode(codeManager: CodeManager) {
return ( return (
isKclEmptyOrOnlySettings(codeManager.code) || codeManager.code === bracket isKclEmptyOrOnlySettings(codeManager.code) ||
codeManager.code === browserAxialFan
) )
} }
@ -304,7 +336,7 @@ export function needsToOnboard(
onboardingStatus: OnboardingStatus onboardingStatus: OnboardingStatus
) { ) {
return ( return (
!location.pathname.includes(PATHS.ONBOARDING.INDEX) && !location.pathname.includes(String(PATHS.ONBOARDING)) &&
(onboardingStatus.length === 0 || (onboardingStatus.length === 0 ||
!(onboardingStatus === 'completed' || onboardingStatus === 'dismissed')) !(onboardingStatus === 'completed' || onboardingStatus === 'dismissed'))
) )
@ -443,3 +475,124 @@ export function TutorialWebConfirmationToast(props: OnboardingUtilDeps) {
</div> </div>
) )
} }
/**
* Find the the element with a given `data-onboarding-id` attribute
* and highlight it on mount, unhighlighting on unmount.
*/
export function useOnboardingHighlight(elementId: string) {
useEffect(() => {
const elementToHighlight = document.querySelector(
`[data-${ONBOARDING_DATA_ATTRIBUTE}="${elementId}"`
)
if (elementToHighlight === null) {
console.error('Text-to-CAD dropdown element not found')
return
}
// There is an ".onboarding-highlight" class defined in index.css
elementToHighlight?.classList.add('onboarding-highlight')
// Remove the highlight on unmount
return () => {
elementToHighlight?.classList.remove('onboarding-highlight')
}
}, [elementId])
}
/**
* Utility hook to set the pane state on mount and unmount.
*/
export function useOnboardingPanes(
onMount: SidebarType[] | undefined = [],
onUnmount: SidebarType[] | undefined = []
) {
const { send } = useModelingContext()
useEffect(() => {
send({
type: 'Set context',
data: {
openPanes: onMount,
},
})
return () =>
send({
type: 'Set context',
data: {
openPanes: onUnmount,
},
})
}, [send])
}
export function isModelingCmdGroupReady(
state: SnapshotFrom<typeof commandBarActor>
) {
// Ensure that the modeling command group is available
if (
state.context.commands.some((command) => command.groupId === 'modeling')
) {
return true
}
return false
}
/**
* Utility onboarding hook to wait for the engine connection to be established
*/
export function useOnModelingCmdGroupReadyOnce(
callback: () => void,
deps: React.DependencyList
) {
const [isReadyOnce, setReadyOnce] = useState(false)
// Set up a subscription to the command bar actor's
// modeling command group
useEffect(() => {
const isReadyNow = isModelingCmdGroupReady(commandBarActor.getSnapshot())
if (isReadyNow) {
setReadyOnce(true)
} else {
const subscription = commandBarActor.subscribe((state) => {
if (isModelingCmdGroupReady(state)) {
setReadyOnce(true)
}
})
return () => subscription.unsubscribe()
}
}, [])
// Fire the callback when the modeling command group is ready
useEffect(() => {
if (isReadyOnce) {
callback()
}
}, [isReadyOnce, ...deps])
}
/**
* If your onboarding step opens the command palette it will mess with keyboard focus.
* To side-step this, use this hook to override a form submission to advance the onboarding.
*/
export function useAdvanceOnboardingOnFormSubmit(
currentSlug?: OnboardingPath,
platform: undefined | keyof typeof onboardingPaths = 'browser'
) {
const [_prev, goToNext] = useOnboardingClicks(currentSlug, platform)
useEffect(() => {
// Override form submission events so the command palette can't be submitted
const formSubmitListener = (e: SubmitEvent) => {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
goToNext()
}
window.addEventListener('submit', formSubmitListener)
// Remove the listener when we leave
return () => {
window.removeEventListener('submit', formSubmitListener)
}
}, [goToNext])
}