Prepare command bar to support modeling commands (#1184)

* Tweak toaster look and feel

* Add icons, tweak plus icon names

* Rename commandBarMeta to commandBarConfig

* Refactor command bar, add support for icons

* Create a tailwind plugin for aria-pressed button state

* Remove overlay from behind command bar

* Clean up toolbar

* Button and other style tweaks

* Icon tweaks follow-up: make old icons work with new sizing

* Delete unused static icons

* More CSS tweaks

* Small CSS tweak to project sidebar

* Add command bar E2E test

* fumpt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* fix typo in a comment

* Fix icon padding (built version only)

* Update onboarding and warning banner icons padding

* Misc minor style fixes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2023-12-06 14:44:13 -05:00
committed by GitHub
parent 38119d5a3b
commit 3ae5393dd7
51 changed files with 1197 additions and 1122 deletions

View File

@ -4,6 +4,7 @@ import { EngineCommand } from '../../src/lang/std/engineConnection'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getUtils } from './test-utils' import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme'
/* /*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -631,3 +632,46 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
// hover again and check it works // hover again and check it works
await selectionSequence() await selectionSequence()
}) })
test('Command bar works and can change a setting', async ({ page }) => {
// Brief boilerplate
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
let cmdSearchBar = page.getByPlaceholder('Search commands')
// First try opening the command bar and closing it
await page.getByRole('button', { name: '⌘K' }).click()
await expect(cmdSearchBar).toBeVisible()
await page.keyboard.press('Escape')
await expect(cmdSearchBar).not.toBeVisible()
// Now try the same, but with the keyboard shortcut, check focus
await page.keyboard.press('Meta+K')
await expect(cmdSearchBar).toBeVisible()
await expect(cmdSearchBar).toBeFocused()
// Try typing in the command bar
await page.keyboard.type('theme')
const themeOption = page.getByRole('option', { name: 'Set Theme' })
await expect(themeOption).toBeVisible()
await themeOption.click()
const themeInput = page.getByPlaceholder(Themes.System)
await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused()
// Select dark theme
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowUp')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
'data-headlessui-state',
'active'
)
await page.keyboard.press('Enter')
// Check the toast appeared
await expect(page.getByText(`Set Theme to "${Themes.Dark}"`)).toBeVisible()
// Check that the theme changed
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 475 B

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 469 B

View File

@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 3.5H4.5V16.5H15.5V8.00001M11 3.5L15.5 8.00001M11 3.5V8.00001H15.5" stroke="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@ -1,106 +0,0 @@
.toolbarWrapper {
@apply relative;
}
.toolbar {
@apply flex gap-4 items-center rounded-full;
@apply border border-cool-20/30 bg-cool-10/50;
}
:global(.dark) .toolbar {
@apply border-cool-100/50 bg-cool-120/50;
}
:global(.sketch) .toolbar {
@apply border-fern-20/20 bg-fern-10/20;
}
:global(.dark .sketch) .toolbar {
@apply border-fern-120/50 bg-fern-100/30;
}
.toolbarCap {
@apply text-sm font-bold;
@apply bg-cool-20/50 text-cool-100;
}
:global(.dark) .toolbarCap {
@apply bg-cool-90/50 text-cool-30;
}
:global(.sketch) .toolbarCap {
@apply bg-fern-20/50 text-fern-100;
}
:global(.dark .sketch) .toolbarCap {
@apply bg-fern-90/50 text-fern-30;
}
.label {
@apply self-stretch flex items-center px-4 py-1;
@apply rounded-l-full;
}
.popoverToggle {
@apply self-stretch m-0 flex items-center px-4 py-1;
@apply rounded-r-full border-none;
@apply hover:bg-cool-20;
}
.toolbarButtons::-webkit-scrollbar {
@apply h-0.5;
}
.toolbarButtons {
@apply flex items-center overflow-x-auto;
scrollbar-width: thin;
}
.toolbarButtons button {
@apply text-chalkboard-90 bg-chalkboard-10/50 border-chalkboard-50 whitespace-nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
@apply gap-1.5 p-0.5 pr-1;
@apply rounded-sm;
}
:global(.dark) .toolbarButtons button {
@apply text-chalkboard-30 bg-chalkboard-90/50 border-chalkboard-50;
}
.toolbarButtons button:hover {
@apply text-cool-90 bg-cool-10;
}
:global(.sketch) .toolbarButtons button:hover {
@apply text-fern-90 bg-fern-10;
}
.toolbarButtons button:disabled {
@apply text-chalkboard-70 bg-chalkboard-30;
}
.toolbarButtons button:disabled:hover {
@apply !bg-inherit !text-inherit cursor-not-allowed;
}
:global(.dark) .toolbarButtons button {
@apply text-chalkboard-20 border-chalkboard-50;
}
:global(.dark) .toolbarButtons button:hover {
@apply text-cool-10 border-chalkboard-50 bg-cool-90;
}
:global(.dark .sketch) .toolbarButtons button:hover {
@apply text-fern-10 border-chalkboard-50 bg-fern-90;
}
:global(.dark) .toolbarButtons button:disabled {
@apply text-chalkboard-40 bg-chalkboard-80;
}
:global(.dark) .popoverToggle {
@apply hover:bg-cool-90;
}
:global(.sketch) .popoverToggle {
@apply hover:bg-fern-20;
}
:global(.dark .sketch) .popoverToggle {
@apply hover:bg-fern-90;
}

View File

@ -1,22 +1,16 @@
import { Fragment, WheelEvent, useRef, useMemo } from 'react' import { WheelEvent, useRef, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch, faX } from '@fortawesome/free-solid-svg-icons'
import { Popover, Transition } from '@headlessui/react'
import styles from './Toolbar.module.css'
import { isCursorInSketchCommandRange } from 'lang/util' import { isCursorInSketchCommandRange } from 'lang/util'
import { ActionIcon } from 'components/ActionIcon'
import { engineCommandManager } from './lang/std/engineConnection' import { engineCommandManager } from './lang/std/engineConnection'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
export const sketchButtonClassnames = { import { ActionButton } from 'components/ActionButton'
background:
'bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-fern-20 dark:group-hover:bg-fern-10 dark:hover:bg-fern-10 group-disabled:bg-chalkboard-50 dark:group-disabled:bg-chalkboard-60 group-hover:group-disabled:bg-chalkboard-50 dark:group-hover:group-disabled:bg-chalkboard-50',
icon: 'text-fern-20 h-auto group-hover:text-fern-10 hover:text-fern-10 dark:text-chalkboard-100 dark:group-hover:text-chalkboard-100 dark:hover:text-chalkboard-100 group-disabled:bg-chalkboard-60 hover:group-disabled:text-inherit',
}
export const Toolbar = () => { export const Toolbar = () => {
const { setCommandBarOpen } = useCommandsContext()
const { state, send, context } = useModelingContext() const { state, send, context } = useModelingContext()
const toolbarButtonsRef = useRef<HTMLSpanElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const bgClassName =
'group-enabled:group-hover:bg-energy-10 group-pressed:bg-energy-10 dark:group-enabled:group-hover:bg-chalkboard-80 dark:group-pressed:bg-chalkboard-80'
const pathId = useMemo( const pathId = useMemo(
() => () =>
isCursorInSketchCommandRange( isCursorInSketchCommandRange(
@ -35,72 +29,102 @@ export const Toolbar = () => {
span.scrollLeft = span.scrollLeft += ev.deltaY span.scrollLeft = span.scrollLeft += ev.deltaY
} }
function ToolbarButtons({ className }: React.HTMLAttributes<HTMLElement>) { function ToolbarButtons({
className = '',
...props
}: React.HTMLAttributes<HTMLElement>) {
return ( return (
<span <ul
{...props}
ref={toolbarButtonsRef} ref={toolbarButtonsRef}
onWheel={handleToolbarButtonsWheelEvent} onWheel={handleToolbarButtonsWheelEvent}
className={styles.toolbarButtons + ' ' + className} className={
'm-0 py-1 rounded-l-sm flex gap-2 items-center overflow-x-auto ' +
className
}
style={{ scrollbarWidth: 'thin' }}
> >
{state.nextEvents.includes('Enter sketch') && ( {state.nextEvents.includes('Enter sketch') && (
<button <li className="contents">
<ActionButton
Element="button"
onClick={() => send({ type: 'Enter sketch' })} onClick={() => send({ type: 'Enter sketch' })}
className="group" icon={{
icon: 'sketch',
bgClassName,
}}
> >
<ActionIcon icon="sketch" className="!p-0.5" size="md" />
<span data-testid="start-sketch">Start Sketch</span> <span data-testid="start-sketch">Start Sketch</span>
</button> </ActionButton>
</li>
)} )}
{state.nextEvents.includes('Enter sketch') && pathId && ( {state.nextEvents.includes('Enter sketch') && pathId && (
<button <li className="contents">
<ActionButton
Element="button"
onClick={() => send({ type: 'Enter sketch' })} onClick={() => send({ type: 'Enter sketch' })}
className="group" icon={{
icon: 'sketch',
bgClassName,
}}
> >
<ActionIcon icon="sketch" className="!p-0.5" size="md" />
Edit Sketch Edit Sketch
</button> </ActionButton>
</li>
)} )}
{state.nextEvents.includes('Cancel') && !state.matches('idle') && ( {state.nextEvents.includes('Cancel') && !state.matches('idle') && (
<button onClick={() => send({ type: 'Cancel' })} className="group"> <li className="contents">
<ActionIcon icon="exit" className="!p-0.5" size="md" /> <ActionButton
Element="button"
onClick={() => send({ type: 'Cancel' })}
icon={{
icon: 'arrowLeft',
bgClassName,
}}
>
Exit Sketch Exit Sketch
</button> </ActionButton>
</li>
)} )}
{state.matches('Sketch') && !state.matches('idle') && ( {state.matches('Sketch') && !state.matches('idle') && (
<button <li className="contents">
<ActionButton
Element="button"
onClick={() => onClick={() =>
state.matches('Sketch.Line Tool') state.matches('Sketch.Line Tool')
? send('CancelSketch') ? send('CancelSketch')
: send('Equip tool') : send('Equip tool')
} }
className={ aria-pressed={state.matches('Sketch.Line Tool')}
'group ' + className="pressed:bg-energy-10/20 dark:pressed:bg-energy-80"
(state.matches('Sketch.Line Tool') icon={{
? '!text-fern-70 !bg-fern-10 !dark:text-fern-20 !border-fern-50' icon: 'line',
: '') bgClassName,
} }}
> >
<ActionIcon icon="line" className="!p-0.5" size="md" />
Line Line
</button> </ActionButton>
</li>
)} )}
{state.matches('Sketch') && ( {state.matches('Sketch') && (
<button <li className="contents">
<ActionButton
Element="button"
onClick={() => onClick={() =>
state.matches('Sketch.Move Tool') state.matches('Sketch.Move Tool')
? send('CancelSketch') ? send('CancelSketch')
: send('Equip move tool') : send('Equip move tool')
} }
className={ aria-pressed={state.matches('Sketch.Move Tool')}
'group ' + className="pressed:bg-energy-10/20 dark:pressed:bg-energy-80"
(state.matches('Sketch.Move Tool') icon={{
? '!text-fern-70 !bg-fern-10 !dark:text-fern-20 !border-fern-50' icon: 'move',
: '') bgClassName,
} }}
> >
<ActionIcon icon="move" className="!p-0.5" size="md" />
Move Move
</button> </ActionButton>
</li>
)} )}
{state.matches('Sketch.SketchIdle') && {state.matches('Sketch.SketchIdle') &&
state.nextEvents state.nextEvents
@ -125,102 +149,66 @@ export const Toolbar = () => {
return 0 return 0
}) })
.map((eventName) => ( .map((eventName) => (
<button <li className="contents">
<ActionButton
Element="button"
className="text-sm"
key={eventName} key={eventName}
onClick={() => send(eventName)} onClick={() => send(eventName)}
className="group"
disabled={ disabled={
!state.nextEvents !state.nextEvents
.filter((event) => state.can(event as any)) .filter((event) => state.can(event as any))
.includes(eventName) .includes(eventName)
} }
title={eventName} title={eventName}
icon={{
icon: 'line',
bgClassName,
}}
> >
<ActionIcon
icon={'line'} // TODO
bgClassName={sketchButtonClassnames.background}
iconClassName={sketchButtonClassnames.icon}
size="md"
/>
{eventName {eventName
.replace('Make segment ', '') .replace('Make segment ', '')
.replace('Constrain ', '')} .replace('Constrain ', '')}
</button> </ActionButton>
</li>
))} ))}
{state.matches('idle') && ( {state.matches('idle') && (
<button <li className="contents">
<ActionButton
Element="button"
className="text-sm"
onClick={() => send('extrude intent')} onClick={() => send('extrude intent')}
disabled={!state.can('extrude intent')} disabled={!state.can('extrude intent')}
className="group"
title={ title={
state.can('extrude intent') state.can('extrude intent')
? 'extrude' ? 'extrude'
: 'sketches need to be closed, or not already extruded' : 'sketches need to be closed, or not already extruded'
} }
icon={{
icon: 'extrude',
bgClassName,
}}
> >
<ActionIcon icon="extrude" className="!p-0.5" size="md" />
Extrude Extrude
</button> </ActionButton>
</li>
)} )}
</span> </ul>
) )
} }
return ( return (
<Popover <div className="max-w-full flex items-stretch rounded-l-sm rounded-r-full bg-chalkboard-10 dark:bg-chalkboard-100 relative">
className={ <menu className="flex-1 pl-1 pr-2 py-0 overflow-hidden rounded-l-sm whitespace-nowrap bg-chalkboard-10 dark:bg-chalkboard-100 border-solid border border-energy-10 dark:border-chalkboard-90 border-r-0">
styles.toolbarWrapper + state.matches('Sketch') ? ' sketch' : ''
}
>
<div className={styles.toolbar}>
<span className={styles.toolbarCap + ' ' + styles.label}>
{state.matches('Sketch') ? '2D' : '3D'}
</span>
<menu className="flex-1 gap-2 py-0.5 overflow-hidden whitespace-nowrap">
<ToolbarButtons /> <ToolbarButtons />
</menu> </menu>
<Popover.Button <ActionButton
className={styles.toolbarCap + ' ' + styles.popoverToggle} Element="button"
onClick={() => setCommandBarOpen(true)}
className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
> >
<FontAwesomeIcon icon={faSearch} /> K
</Popover.Button> </ActionButton>
</div> </div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="fixed inset-0 bg-chalkboard-110/20 dark:bg-chalkboard-110/50" />
</Transition>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="transition ease-out duration-75"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-2"
>
<Popover.Panel className="absolute top-0 w-screen max-w-xl left-1/2 -translate-x-1/2 flex flex-col gap-8 bg-chalkboard-10 dark:bg-chalkboard-100 p-5 rounded border border-chalkboard-20/30 dark:border-chalkboard-70/50">
<section className="flex justify-between items-center">
<p
className={`${styles.toolbarCap} ${styles.label} !self-center rounded-r-full w-fit`}
>
You're in {state.matches('Sketch') ? '2D' : '3D'}
</p>
<Popover.Button className="p-2 flex items-center justify-center rounded-sm bg-chalkboard-20 text-chalkboard-110 dark:bg-chalkboard-70 dark:text-chalkboard-20 border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-60">
<FontAwesomeIcon icon={faX} className="w-4 h-4" />
</Popover.Button>
</section>
<section>
<ToolbarButtons className="flex-wrap" />
</section>
</Popover.Panel>
</Transition>
</Popover>
) )
} }

View File

@ -39,16 +39,16 @@ type ActionButtonProps =
| ActionButtonAsElement | ActionButtonAsElement
export const ActionButton = (props: ActionButtonProps) => { export const ActionButton = (props: ActionButtonProps) => {
const classNames = `group mono text-base flex items-center gap-2 rounded-sm border border-chalkboard-40 dark:border-chalkboard-60 hover:border-liquid-40 dark:hover:bg-chalkboard-90 p-[3px] text-chalkboard-110 dark:text-chalkboard-10 hover:text-chalkboard-110 hover:dark:text-chalkboard-10 ${ const classNames = `action-button m-0 group mono text-sm flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 p-[3px] text-chalkboard-100 dark:text-chalkboard-10 ${
props.icon ? 'pr-2' : 'px-2' props.icon ? 'pr-2' : 'px-2'
} ${props.className || ''}` } ${props.className ? props.className : ''}`
switch (props.Element) { switch (props.Element) {
case 'button': { case 'button': {
// Note we have to destructure 'className' and 'Element' out of props // Note we have to destructure 'className' and 'Element' out of props
// because we don't want to pass them to the button element; // because we don't want to pass them to the button element;
// the same is true for the other cases below. // the same is true for the other cases below.
const { Element, icon, children, className, ...rest } = props const { Element, icon, children, className: _className, ...rest } = props
return ( return (
<button className={classNames} {...rest}> <button className={classNames} {...rest}>
{props.icon && <ActionIcon {...icon} />} {props.icon && <ActionIcon {...icon} />}
@ -57,7 +57,14 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
case 'link': { case 'link': {
const { Element, to, icon, children, className, ...rest } = props const {
Element,
to,
icon,
children,
className: _className,
...rest
} = props
return ( return (
<Link to={to || paths.INDEX} className={classNames} {...rest}> <Link to={to || paths.INDEX} className={classNames} {...rest}>
{icon && <ActionIcon {...icon} />} {icon && <ActionIcon {...icon} />}
@ -66,7 +73,14 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
case 'externalLink': { case 'externalLink': {
const { Element, to, icon, children, className, ...rest } = props const {
Element,
to,
icon,
children,
className: _className,
...rest
} = props
return ( return (
<Link <Link
to={to || paths.INDEX} to={to || paths.INDEX}
@ -80,7 +94,7 @@ export const ActionButton = (props: ActionButtonProps) => {
) )
} }
default: { default: {
const { Element, icon, children, className, ...rest } = props const { Element, icon, children, className: _className, ...rest } = props
if (!Element) throw new Error('Element is required') if (!Element) throw new Error('Element is required')
return ( return (

View File

@ -7,10 +7,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { CustomIcon, CustomIconName } from './CustomIcon' import { CustomIcon, CustomIconName } from './CustomIcon'
const iconSizes = { const iconSizes = {
sm: 12, xs: 12,
md: 14.4, sm: 14,
lg: 20, md: 20,
xl: 28, lg: 24,
} }
export interface ActionIconProps extends React.PropsWithChildren { export interface ActionIconProps extends React.PropsWithChildren {
@ -30,20 +30,14 @@ export const ActionIcon = ({
children, children,
}: ActionIconProps) => { }: ActionIconProps) => {
// By default, we reverse the icon color and background color in dark mode // By default, we reverse the icon color and background color in dark mode
const computedIconClassName = const computedIconClassName = `h-auto dark:text-energy-10 !group-disabled:text-chalkboard-60 !group-disabled:text-chalkboard-60 ${iconClassName}`
iconClassName ||
`text-liquid-20 h-auto group-hover:text-liquid-10 hover:text-liquid-10 dark:text-chalkboard-100 dark:group-hover:text-chalkboard-100 dark:hover:text-chalkboard-100 group-disabled:bg-chalkboard-50 dark:group-disabled:bg-chalkboard-60 group-hover:group-disabled:bg-chalkboard-50 dark:group-hover:group-disabled:bg-chalkboard-50`
const computedBgClassName = const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-90 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 ${bgClassName}`
bgClassName ||
`bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-liquid-20 dark:group-hover:bg-liquid-10 dark:hover:bg-liquid-10 group-disabled:bg-chalkboard-80 dark:group-disabled:bg-chalkboard-80`
return ( return (
<div <div
className={ className={
`p-${ `w-fit inline-grid place-content-center ${className} ` +
size === 'xl' ? '2' : '1'
} w-fit inline-grid place-content-center ${className} ` +
computedBgClassName computedBgClassName
} }
> >

View File

@ -5,6 +5,8 @@ import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import styles from './AppHeader.module.css' import styles from './AppHeader.module.css'
import { NetworkHealthIndicator } from './NetworkHealthIndicator' import { NetworkHealthIndicator } from './NetworkHealthIndicator'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { ActionButton } from './ActionButton'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
@ -20,13 +22,14 @@ export const AppHeader = ({
className = '', className = '',
enableMenu = false, enableMenu = false,
}: AppHeaderProps) => { }: AppHeaderProps) => {
const { setCommandBarOpen } = useCommandsContext()
const { auth } = useGlobalStateContext() const { auth } = useGlobalStateContext()
const user = auth?.context?.user const user = auth?.context?.user
return ( return (
<header <header
className={ className={
(showToolbar ? 'w-full grid ' : 'flex justify-between ') + 'w-full grid ' +
styles.header + styles.header +
' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' + ' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' +
className className
@ -38,18 +41,31 @@ export const AppHeader = ({
file={project?.file} file={project?.file}
/> />
{/* Toolbar if the context deems it */} {/* Toolbar if the context deems it */}
{showToolbar && ( <div className="flex-grow flex justify-center max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl">
<div className="max-w-lg md:max-w-xl lg:max-w-2xl xl:max-w-4xl 2xl:max-w-5xl"> {showToolbar ? (
<Toolbar /> <Toolbar />
</div> ) : (
<ActionButton
Element="button"
onClick={() => setCommandBarOpen(true)}
className="text-sm self-center flex items-center w-fit gap-3"
>
Command Palette{' '}
<kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
K
</kbd>
</ActionButton>
)} )}
</div>
<div className="flex items-center gap-1 ml-auto">
{/* If there are children, show them, otherwise show User menu */} {/* If there are children, show them, otherwise show User menu */}
{children || ( {children || (
<div className="flex items-center gap-1 ml-auto"> <>
<NetworkHealthIndicator /> <NetworkHealthIndicator />
<UserSidebarMenu user={user} /> <UserSidebarMenu user={user} />
</div> </>
)} )}
</div>
</header> </header>
) )
} }

View File

@ -1,13 +1,13 @@
.button { .button {
@apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm; @apply flex justify-between items-center gap-2 px-2 py-1 text-left border-none rounded-sm;
@apply font-mono text-xs font-bold select-none text-chalkboard-90; @apply font-mono text-xs font-bold select-none text-chalkboard-90;
@apply ui-active:bg-liquid-10/50 ui-active:text-liquid-90; @apply ui-active:bg-energy-10/50 ui-active:text-inherit;
@apply transition-colors ease-out; @apply transition-colors ease-out;
} }
:global(.dark) .button { :global(.dark) .button {
@apply text-chalkboard-30; @apply text-chalkboard-30;
@apply ui-active:bg-chalkboard-80 ui-active:text-liquid-10; @apply ui-active:bg-chalkboard-80 ui-active:text-energy-10;
} }
.button small { .button small {

View File

@ -30,8 +30,10 @@ export const CodeMenu = ({ children }: PropsWithChildren) => {
<Menu.Button className="p-0 border-none relative"> <Menu.Button className="p-0 border-none relative">
<ActionIcon <ActionIcon
icon={faEllipsis} icon={faEllipsis}
className="p-1"
size="sm"
bgClassName={ bgClassName={
'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-liquid-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded' 'bg-chalkboard-20 dark:bg-chalkboard-110 hover:bg-energy-10/50 hover:dark:bg-chalkboard-90 ui-active:bg-chalkboard-80 ui-active:dark:bg-chalkboard-90 rounded-sm'
} }
iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'} iconClassName={'text-chalkboard-90 dark:text-chalkboard-40'}
/> />

View File

@ -24,16 +24,17 @@ export const PanelHeader = ({
}: CollapsiblePanelProps) => { }: CollapsiblePanelProps) => {
return ( return (
<summary className={styles.header}> <summary className={styles.header}>
<div className="flex gap-2 align-center flex-1"> <div className="flex gap-2 align-center items-center flex-1">
<ActionIcon <ActionIcon
icon={icon} icon={icon}
className="p-1"
size="sm"
bgClassName={ bgClassName={
'bg-chalkboard-30 dark:bg-chalkboard-90 group-open:bg-chalkboard-80 rounded ' + 'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 group-open:border dark:group-open:border-chalkboard-60 rounded-sm ' +
(iconClassNames?.bg || '') (iconClassNames?.bg || '')
} }
iconClassName={ iconClassName={
'text-chalkboard-90 dark:text-chalkboard-40 group-open:text-liquid-10 ' + 'group-open:text-energy-10 ' + (iconClassNames?.icon || '')
(iconClassNames?.icon || '')
} }
/> />
{title} {title}

View File

@ -4,18 +4,22 @@ import {
Fragment, Fragment,
SetStateAction, SetStateAction,
createContext, createContext,
useEffect,
useRef,
useState, useState,
} from 'react' } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands' import {
Command,
CommandArgument,
CommandArgumentOption,
} from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from './CustomIcon'
export type SortedCommand = { type ComboboxOption = Command | CommandArgumentOption
item: Partial<Command | SubCommand> & { name: string } type CommandArgumentData = [string, any]
}
export const CommandsContext = createContext( export const CommandsContext = createContext(
{} as { {} as {
@ -35,12 +39,24 @@ export const CommandBarProvider = ({
const [commands, internalSetCommands] = useState([] as Command[]) const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false) const [commandBarOpen, setCommandBarOpen] = useState(false)
function sortCommands(a: Command, b: Command) {
if (b.owner === 'auth') return -1
if (a.owner === 'auth') return 1
return a.name.localeCompare(b.name)
}
useEffect(() => console.log('commands updated', commands), [commands])
const addCommands = (newCommands: Command[]) => { const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands]) internalSetCommands((prevCommands) =>
[...newCommands, ...prevCommands].sort(sortCommands)
)
} }
const removeCommands = (newCommands: Command[]) => { const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command)) prevCommands
.filter((command) => !newCommands.includes(command))
.sort(sortCommands)
) )
} }
@ -63,152 +79,117 @@ export const CommandBarProvider = ({
const CommandBar = () => { const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext() const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys(['meta+k', 'meta+/'], () => { useHotkeys(['meta+k', 'meta+/'], () => {
if (commands.length === 0) return if (commands?.length === 0) return
setCommandBarOpen(!commandBarOpen) setCommandBarOpen(!commandBarOpen)
}) })
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>( const [selectedCommand, setSelectedCommand] = useState<Command>()
null const [commandArguments, setCommandArguments] = useState<CommandArgument[]>(
[]
) )
// keep track of the current subcommand index const [commandArgumentData, setCommandArgumentData] = useState<
const [subCommandIndex, setSubCommandIndex] = useState<number>() CommandArgumentData[]
const [subCommandData, setSubCommandData] = useState<{ >([])
[key: string]: string const [commandArgumentIndex, setCommandArgumentIndex] = useState<number>(0)
}>({})
// if the subcommand index is null, we're not in a subcommand
const inSubCommand =
selectedCommand &&
'meta' in selectedCommand.item &&
selectedCommand.item.meta?.args !== undefined &&
subCommandIndex !== undefined
const currentSubCommand =
inSubCommand && 'meta' in selectedCommand.item
? selectedCommand.item.meta?.args[subCommandIndex]
: undefined
const [query, setQuery] = useState('')
const availableCommands =
inSubCommand && currentSubCommand
? currentSubCommand.type === 'string'
? query
? [{ name: query }]
: currentSubCommand.options
: currentSubCommand.options
: commands
const fuse = new Fuse(availableCommands || [], {
keys: ['name', 'description'],
})
const filteredCommands = query
? fuse.search(query)
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
function clearState() { function clearState() {
setQuery('')
setCommandBarOpen(false) setCommandBarOpen(false)
setSelectedCommand(null) setSelectedCommand(undefined)
setSubCommandIndex(undefined) setCommandArguments([])
setSubCommandData({}) setCommandArgumentData([])
setCommandArgumentIndex(0)
} }
function handleCommandSelection(entry: SortedCommand) { function selectCommand(command: Command) {
// If we have subcommands and have not yet gathered all the console.log('selecting command', command)
// data required from them, set the selected command to the if (!('args' in command && command.args?.length)) {
// current command and increment the subcommand index submitCommand({ command })
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
setSelectedCommand(entry)
setSubCommandIndex(0)
setQuery('')
return
}
const { item } = entry
// If we have just selected a command with no subcommands, run it
const isCommandWithoutSubcommands =
'callback' in item && !('meta' in item && item.meta)
if (isCommandWithoutSubcommands) {
if (item.callback === undefined) return
item.callback()
setCommandBarOpen(false)
return
}
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (
selectedCommand &&
subCommandIndex !== undefined &&
'meta' in selectedCommand.item
) {
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
if (subCommand) {
const newSubCommandData = {
...subCommandData,
[subCommand.name]: item.name,
}
const newSubCommandIndex = subCommandIndex + 1
// If we have subcommands and have gathered all the data required
// from them, run the command with the gathered data
if (
selectedCommand.item.callback &&
selectedCommand.item.meta?.args.length === newSubCommandIndex
) {
selectedCommand.item.callback(newSubCommandData)
setCommandBarOpen(false)
} else { } else {
// Otherwise, set the subcommand data and increment the subcommand index setCommandArguments(command.args)
setSubCommandData(newSubCommandData) setSelectedCommand(command)
setSubCommandIndex(newSubCommandIndex) }
setQuery('') }
function stepBack() {
if (!selectedCommand) {
clearState()
} else {
if (commandArgumentIndex === 0) {
setSelectedCommand(undefined)
} else {
setCommandArgumentIndex((prevIndex) => Math.max(0, prevIndex - 1))
}
if (commandArgumentData.length > 0) {
setCommandArgumentData((prevData) => prevData.slice(0, -1))
} }
} }
} }
function appendCommandArgumentData(data: { name: any }) {
const transformedData = [
commandArguments[commandArgumentIndex].name,
data.name,
]
if (commandArgumentIndex + 1 === commandArguments.length) {
submitCommand({
dataArr: [
...commandArgumentData,
transformedData,
] as CommandArgumentData[],
})
} else {
setCommandArgumentData(
(prevData) => [...prevData, transformedData] as CommandArgumentData[]
)
setCommandArgumentIndex((prevIndex) => prevIndex + 1)
}
}
function submitCommand({
command = selectedCommand,
dataArr = commandArgumentData,
}) {
console.log('submitting command', command, dataArr)
if (dataArr.length === 0) {
command?.callback()
} else {
const data = Object.fromEntries(dataArr)
console.log('submitting data', data)
command?.callback(data)
}
setCommandBarOpen(false)
} }
function getDisplayValue(command: Command) { function getDisplayValue(command: Command) {
if (command.meta?.displayValue === undefined || !command.meta.args) if (
'args' in command &&
command.args &&
command.args?.length > 0 &&
'formatFunction' in command &&
command.formatFunction
) {
command.formatFunction(
command.args.map((c, i) =>
commandArgumentData[i] ? commandArgumentData[i][0] : `<${c.name}>`
)
)
}
return command.name return command.name
return command.meta?.displayValue(
command.meta.args.map((c) =>
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
)
)
} }
return ( return (
<Transition.Root <Transition.Root
show={ show={commandBarOpen || false}
commandBarOpen &&
availableCommands?.length !== undefined &&
availableCommands.length > 0
}
as={Fragment}
afterLeave={() => clearState()} afterLeave={() => clearState()}
as={Fragment}
> >
<Dialog <Dialog
onClose={() => { onClose={() => {
setCommandBarOpen(false) setCommandBarOpen(false)
clearState()
}} }}
className="fixed inset-0 z-40 overflow-y-auto p-4 pt-[25vh]" className="fixed inset-0 z-40 overflow-y-auto pb-4 pt-1"
> >
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Dialog.Overlay className="fixed inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child>
<Transition.Child <Transition.Child
enter="duration-100 ease-out" enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95" enterFrom="opacity-0 scale-95"
@ -216,75 +197,208 @@ const CommandBar = () => {
leave="duration-75 ease-in" leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
as={Fragment}
> >
<Combobox <Dialog.Panel
value={selectedCommand} className="relative w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
onChange={handleCommandSelection}
className="relative w-full max-w-xl p-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div" as="div"
> >
<div className="flex items-center gap-2"> {!(
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" /> commandArguments &&
<div> commandArguments.length &&
{inSubCommand && ( selectedCommand
<p className="text-liquid-70 dark:text-liquid-30"> ) ? (
{selectedCommand.item && <CommandComboBox
getDisplayValue(selectedCommand.item as Command)} options={commands}
</p> handleSelection={selectCommand}
)} stepBack={stepBack}
<Combobox.Input />
onChange={(event) => setQuery(event.target.value)} ) : (
className="w-full bg-transparent focus:outline-none" <>
onKeyDown={(event) => { <div className="px-4 text-sm flex flex-wrap gap-2">
if (event.metaKey && event.key === 'k') <p className="pr-4 flex gap-2 items-center">
setCommandBarOpen(false) {selectedCommand &&
if ( 'icon' in selectedCommand &&
inSubCommand && selectedCommand.icon && (
event.key === 'Backspace' && <CustomIcon
!event.currentTarget.value name={selectedCommand.icon}
) { className="w-5 h-5"
setSubCommandIndex(subCommandIndex - 1)
setSelectedCommand(null)
}
}}
displayValue={(command: SortedCommand) =>
command !== null ? command.item.name : ''
}
placeholder={
inSubCommand
? `Enter <${currentSubCommand?.name}>`
: 'Search for a command'
}
value={query}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/> />
</div>
</div>
<Combobox.Options static className="overflow-y-auto max-h-96">
{filteredCommands?.map((commandResult) => (
<Combobox.Option
key={commandResult.item.name}
value={commandResult}
className="px-2 py-1 my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90"
>
<p>{commandResult.item.name}</p>
{(commandResult.item as SubCommand).description && (
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
{(commandResult.item as SubCommand).description}
</p>
)} )}
</Combobox.Option> {getDisplayValue(selectedCommand)}
</p>
{commandArguments.map((arg, i) => (
<p
key={arg.name}
className={`w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
i === commandArgumentIndex
? 'bg-energy-10/50 dark:bg-energy-10/20 border-energy-10 dark:border-energy-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
{commandArgumentIndex >= i && commandArgumentData[i] ? (
commandArgumentData[i][1]
) : arg.defaultValue ? (
arg.defaultValue
) : (
<em>{arg.name}</em>
)}
</p>
))} ))}
</Combobox.Options> </div>
</Combobox> <div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
<Argument
arg={commandArguments[commandArgumentIndex]}
appendCommandArgumentData={appendCommandArgumentData}
stepBack={stepBack}
/>
</>
)}
</Dialog.Panel>
</Transition.Child> </Transition.Child>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
) )
} }
function Argument({
arg,
appendCommandArgumentData,
stepBack,
}: {
arg: CommandArgument
appendCommandArgumentData: Dispatch<SetStateAction<any>>
stepBack: () => void
}) {
const { setCommandBarOpen } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [arg, inputRef])
return arg.type === 'select' ? (
<CommandComboBox
options={arg.options}
handleSelection={appendCommandArgumentData}
stepBack={stepBack}
placeholder="Select an option"
/>
) : (
<form
onSubmit={(event) => {
event.preventDefault()
appendCommandArgumentData({ name: inputRef.current?.value })
}}
>
<label className="flex items-center mx-4 my-4">
<span className="px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
{arg.name}
</span>
<input
ref={inputRef}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value"
defaultValue={arg.defaultValue}
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
autoFocus
/>
</label>
</form>
)
}
export default CommandBarProvider export default CommandBarProvider
function CommandComboBox({
options,
handleSelection,
stepBack,
placeholder,
}: {
options: ComboboxOption[]
handleSelection: Dispatch<SetStateAction<any>>
stepBack: () => void
placeholder?: string
}) {
const { setCommandBarOpen } = useCommandsContext()
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<ComboboxOption[]>()
const defaultOption =
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
const fuse = new Fuse(options, {
keys: ['name', 'description'],
threshold: 0.3,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : options)
}, [query])
return (
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
<CustomIcon
name="search"
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
/>
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||
'Search commands'
}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
autoFocus
/>
</div>
<Combobox.Options
static
className="overflow-y-auto max-h-96 cursor-pointer"
>
{filteredOptions?.map((option) => (
<Combobox.Option
key={option.name}
value={option}
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
>
{'icon' in option && option.icon && (
<CustomIcon
name={option.icon}
className="w-5 h-5 dark:text-energy-10"
/>
)}
<p className="flex-grow">{option.name} </p>
{'isCurrent' in option && option.isCurrent && (
<small className="text-chalkboard-70 dark:text-chalkboard-50">
current
</small>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
}

View File

@ -1,14 +1,21 @@
export type CustomIconName = export type CustomIconName =
| 'createFile' | 'arrowDown'
| 'createFolder' | 'arrowLeft'
| 'arrowRight'
| 'arrowUp'
| 'close'
| 'equal' | 'equal'
| 'exit'
| 'extrude' | 'extrude'
| 'file' | 'file'
| 'filePlus'
| 'folder'
| 'folderPlus'
| 'gear'
| 'horizontal' | 'horizontal'
| 'line' | 'line'
| 'move' | 'move'
| 'parallel' | 'parallel'
| 'search'
| 'sketch' | 'sketch'
| 'vertical' | 'vertical'
@ -19,7 +26,7 @@ export const CustomIcon = ({
name: CustomIconName name: CustomIconName
} & React.SVGProps<SVGSVGElement>) => { } & React.SVGProps<SVGSVGElement>) => {
switch (name) { switch (name) {
case 'createFile': case 'arrowDown':
return ( return (
<svg <svg
{...props} {...props}
@ -30,12 +37,12 @@ export const CustomIcon = ({
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z" d="M10 17.7071L9.64648 17.3535L6.14648 13.8535L6.85359 13.1464L9.50004 15.7929V2.99997H10.5V15.7929L13.1465 13.1464L13.8536 13.8535L10.3536 17.3535L10 17.7071Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
) )
case 'createFolder': case 'arrowLeft':
return ( return (
<svg <svg
{...props} {...props}
@ -46,7 +53,55 @@ export const CustomIcon = ({
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z" d="M2.29291 10L2.64646 9.64645L6.14646 6.14645L6.85357 6.85356L4.20712 9.50001L17 9.50001V10.5L4.20712 10.5L6.85357 13.1465L6.14646 13.8536L2.64646 10.3536L2.29291 10Z"
fill="currentColor"
/>
</svg>
)
case 'arrowRight':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.7071 10L17.3536 10.3536L13.8536 13.8536L13.1464 13.1465L15.7929 10.5H3V9.50001H15.7929L13.1464 6.85356L13.8536 6.14645L17.3536 9.64645L17.7071 10Z"
fill="currentColor"
/>
</svg>
)
case 'arrowUp':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 2.29288L10.3536 2.64643L13.8536 6.14643L13.1465 6.85354L10.5 4.20709V17H9.50004V4.20709L6.85359 6.85354L6.14648 6.14643L9.64648 2.64643L10 2.29288Z"
fill="currentColor"
/>
</svg>
)
case 'close':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.2929 10L6.46448 7.17158L7.17158 6.46448L10 9.2929L12.8284 6.46448L13.5355 7.17158L10.7071 10L13.5355 12.8284L12.8284 13.5355L10 10.7071L7.17158 13.5355L6.46448 12.8284L9.2929 10Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
@ -65,21 +120,6 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'exit':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17 10L3 10M3 10L6.5 6.5M3 10L6.5 13.5"
stroke="currentColor"
/>
</svg>
)
case 'extrude': case 'extrude':
return ( return (
<svg <svg
@ -105,8 +145,74 @@ export const CustomIcon = ({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M11 3.5H4.5V16.5H15.5V8.00001M11 3.5L15.5 8.00001M11 3.5V8.00001H15.5" fillRule="evenodd"
stroke="currentColor" clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V16.5V17H15.5H4.5H4V16.5V3.5V3ZM5 4V16H15V8.50001H11H10.5V8.00001V4H5ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711Z"
fill="currentColor"
/>
</svg>
)
case 'filePlus':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM13 12V14H11V15H13V17H14V15H16V14H14V12H13Z"
fill="currentColor"
/>
</svg>
)
case 'folder':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V16V16.5H16H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM15.5 8H4.5V15.5H15.5V8Z"
fill="currentColor"
/>
</svg>
)
case 'folderPlus':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.5 3.5H4H7H7.16667L7.3 3.6L9.16667 5H16H16.5V5.5V7.5V10.3773C16.1992 10.1362 15.8628 9.93763 15.5 9.79076V8H4.5V15.5H10.5351C10.7529 15.8764 11.0302 16.2141 11.3542 16.5H4H3.5V16V7.5V4V3.5ZM4.5 4.5V7H15.5V6H9H8.83333L8.7 5.9L6.83333 4.5H4.5ZM13.5 11V13H11.5V14H13.5V16H14.5V14H16.5V13H14.5V11H13.5Z"
fill="currentColor"
/>
</svg>
)
case 'gear':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.61477 3.0884L5.87402 4.67077L6.50004 5.75505L5.25004 7.92011H4.0047V11.07H5.25004L6.50004 13.2351L5.86973 14.3268L8.62776 15.9191L9.24503 14.85H11.745L12.3647 15.9234L15.1416 14.3202L14.5151 13.2351L15.7651 11.07H16.9951V7.92011H15.7651L14.5151 5.75505L15.1373 4.67741L12.3778 3.08423L11.7451 4.18012H9.24508L8.61477 3.0884ZM10.4999 13C12.4329 13 13.9999 11.433 13.9999 9.50003C13.9999 7.56703 12.4329 6.00003 10.4999 6.00003C8.56687 6.00003 6.99986 7.56703 6.99986 9.50003C6.99986 11.433 8.56687 13 10.4999 13Z"
fill="currentColor"
/> />
</svg> </svg>
) )
@ -174,6 +280,22 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'search':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.016 9.00482C14.016 10.662 12.6731 12.0048 11.0172 12.0048C9.3613 12.0048 8.01841 10.662 8.01841 9.00482C8.01841 7.34768 9.3613 6.00482 11.0172 6.00482C12.6731 6.00482 14.016 7.34768 14.016 9.00482ZM15.016 9.00482C15.016 11.214 13.2257 13.0048 11.0172 13.0048C10.082 13.0048 9.22178 12.6837 8.54074 12.1456L5.6912 14.9952L4.98409 14.2881L7.83921 11.433C7.32431 10.7597 7.01841 9.91799 7.01841 9.00482C7.01841 6.79568 8.80873 5.00482 11.0172 5.00482C13.2257 5.00482 15.016 6.79568 15.016 9.00482Z"
fill="currentColor"
/>
</svg>
)
case 'sketch': case 'sketch':
return ( return (
<svg <svg

View File

@ -1,7 +1,6 @@
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useStore } from '../useStore' import { useStore } from '../useStore'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { faX } from '@fortawesome/free-solid-svg-icons'
const DownloadAppBanner = () => { const DownloadAppBanner = () => {
const { isBannerDismissed, setBannerDismissed } = useStore((s) => ({ const { isBannerDismissed, setBannerDismissed } = useStore((s) => ({
@ -24,7 +23,8 @@ const DownloadAppBanner = () => {
Element="button" Element="button"
onClick={() => setBannerDismissed(true)} onClick={() => setBannerDismissed(true)}
icon={{ icon={{
icon: faX, icon: 'close',
className: 'p-1',
bgClassName: bgClassName:
'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80', 'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80',
iconClassName: iconClassName:

View File

@ -118,6 +118,8 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
Element="button" Element="button"
icon={{ icon={{
icon: faFileExport, icon: faFileExport,
className: 'p-1',
size: 'sm',
iconClassName: className?.icon, iconClassName: className?.icon,
bgClassName: className?.bg, bgClassName: className?.bg,
}} }}
@ -212,6 +214,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
onClick={closeModal} onClick={closeModal}
icon={{ icon={{
icon: faXmark, icon: faXmark,
className: 'p-1',
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
iconClassName: iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', 'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
@ -223,7 +226,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
<ActionButton <ActionButton
Element="button" Element="button"
type="submit" type="submit"
icon={{ icon: faFileExport }} icon={{ icon: faFileExport, className: 'p-1' }}
> >
Export Export
</ActionButton> </ActionButton>

View File

@ -325,16 +325,17 @@ export const FileTree = ({
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-30/50 dark:bg-chalkboard-70/50"> <div className="flex items-center gap-1 px-4 py-1 bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
<h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2> <h2 className="flex-1 m-0 p-0 text-sm mono">Files</h2>
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: 'createFile', icon: 'filePlus',
iconClassName: '!text-energy-80 dark:!text-energy-20', iconClassName: '!text-energy-80 dark:!text-energy-20',
bgClassName: 'hover:bg-energy-10/50 dark:hover:bg-transparent', bgClassName:
'bg-chalkboard-20/50 hover:bg-energy-10/50 dark:hover:bg-transparent',
}} }}
className="!p-0 border-none bg-transparent !outline-none" className="!p-0 bg-transparent !outline-none"
onClick={createFile} onClick={createFile}
> >
<Tooltip position="inlineStart" delay={750}> <Tooltip position="inlineStart" delay={750}>
@ -345,11 +346,12 @@ export const FileTree = ({
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: 'createFolder', icon: 'folderPlus',
iconClassName: '!text-energy-80 dark:!text-energy-20', iconClassName: '!text-energy-80 dark:!text-energy-20',
bgClassName: 'hover:bg-energy-10/50 dark:hover:bg-transparent', bgClassName:
'bg-chalkboard-20/50 hover:bg-energy-10/50 dark:hover:bg-transparent',
}} }}
className="!p-0 border-none bg-transparent !outline-none" className="!p-0 bg-transparent !outline-none"
onClick={createFolder} onClick={createFolder}
> >
<Tooltip position="inlineStart" delay={750}> <Tooltip position="inlineStart" delay={750}>

View File

@ -2,7 +2,7 @@ import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { paths } from '../Router' import { paths } from '../Router'
import { import {
authCommandBarMeta, authCommandBarConfig,
authMachine, authMachine,
TOKEN_PERSIST_KEY, TOKEN_PERSIST_KEY,
} from '../machines/authMachine' } from '../machines/authMachine'
@ -11,7 +11,7 @@ import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands' import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { import {
SETTINGS_PERSIST_KEY, SETTINGS_PERSIST_KEY,
settingsCommandBarMeta, settingsCommandBarConfig,
settingsMachine, settingsMachine,
} from 'machines/settingsMachine' } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
@ -85,7 +85,7 @@ export const GlobalStateProvider = ({
send: settingsSend, send: settingsSend,
commands, commands,
owner: 'settings', owner: 'settings',
commandBarMeta: settingsCommandBarMeta, commandBarConfig: settingsCommandBarConfig,
}) })
// Listen for changes to the system theme and update the app theme accordingly // Listen for changes to the system theme and update the app theme accordingly
@ -124,7 +124,7 @@ export const GlobalStateProvider = ({
state: authState, state: authState,
send: authSend, send: authSend,
commands, commands,
commandBarMeta: authCommandBarMeta, commandBarConfig: authCommandBarConfig,
owner: 'auth', owner: 'auth',
}) })

View File

@ -1,5 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar' import CommandBarProvider from './CommandBar'

View File

@ -46,7 +46,7 @@ export const NetworkHealthIndicator = () => {
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button
className={ className={
'p-0 border-none relative ' + 'p-0 border-none bg-transparent dark:bg-transparent relative ' +
(hasIssues (hasIssues
? 'focus-visible:outline-destroy-80' ? 'focus-visible:outline-destroy-80'
: 'focus-visible:outline-succeed-80') : 'focus-visible:outline-succeed-80')
@ -56,15 +56,17 @@ export const NetworkHealthIndicator = () => {
<span className="sr-only">Network Health</span> <span className="sr-only">Network Health</span>
<ActionIcon <ActionIcon
icon={faWifi} icon={faWifi}
className="p-1"
iconClassName={ iconClassName={
hasIssues hasIssues
? 'text-destroy-80 dark:text-destroy-30' ? 'text-destroy-80 dark:text-destroy-30'
: 'text-succeed-80 dark:text-succeed-30' : 'text-succeed-80 dark:text-succeed-30'
} }
bgClassName={ bgClassName={
hasIssues 'bg-transparent dark:bg-transparent ' +
(hasIssues
? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded' ? 'hover:bg-destroy-10/50 hover:dark:bg-destroy-80/50 rounded'
: 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded' : 'hover:bg-succeed-10/50 hover:dark:bg-succeed-80/50 rounded')
} }
/> />
</Popover.Button> </Popover.Button>

View File

@ -1,4 +1,4 @@
import { FormEvent, useEffect, useState } from 'react' import { FormEvent, useEffect, useRef, useState } from 'react'
import { type ProjectWithEntryPointMetadata, paths } from '../Router' import { type ProjectWithEntryPointMetadata, paths } from '../Router'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
@ -31,9 +31,11 @@ function ProjectCard({
const [numberOfParts, setNumberOfParts] = useState(1) const [numberOfParts, setNumberOfParts] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0) const [numberOfFolders, setNumberOfFolders] = useState(0)
let inputRef = useRef<HTMLInputElement>(null)
function handleSave(e: FormEvent<HTMLFormElement>) { function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
handleRenameProject(e, project).then(() => setIsEditing(false)) void handleRenameProject(e, project).then(() => setIsEditing(false))
} }
function getDisplayedTime(date: Date) { function getDisplayedTime(date: Date) {
@ -52,36 +54,48 @@ function ProjectCard({
setNumberOfParts(kclFileCount) setNumberOfParts(kclFileCount)
setNumberOfFolders(kclDirCount) setNumberOfFolders(kclDirCount)
} }
getNumberOfParts() void getNumberOfParts()
}, [project.path]) }, [project.path])
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [inputRef])
return ( return (
<li <li
{...props} {...props}
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-chalkboard-30 dark:hover:border-chalkboard-80" className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-90 hover:border-energy-10 dark:hover:border-chalkboard-70 hover:bg-energy-10/20 dark:hover:bg-chalkboard-90"
> >
{isEditing ? ( {isEditing ? (
<form onSubmit={handleSave} className="flex gap-2 items-center"> <form onSubmit={handleSave} className="flex gap-2 items-center">
<input <input
className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1" className="dark:bg-chalkboard-80 dark:border-chalkboard-40 min-w-0 p-1 selection:bg-energy-10/20 focus:outline-none"
type="text" type="text"
id="newProjectName" id="newProjectName"
name="newProjectName" name="newProjectName"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
defaultValue={project.name} defaultValue={project.name}
autoFocus={true} ref={inputRef}
/> />
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<ActionButton <ActionButton
Element="button" Element="button"
type="submit" type="submit"
icon={{ icon: faCheck, size: 'sm' }} icon={{ icon: faCheck, size: 'sm', className: 'p-1' }}
className="!p-0" className="!p-0"
></ActionButton> ></ActionButton>
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faX, size: 'sm' }} icon={{
icon: faX,
size: 'sm',
iconClassName: 'dark:!text-chalkboard-20',
className: 'p-1',
}}
className="!p-0" className="!p-0"
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
/> />
@ -91,8 +105,8 @@ function ProjectCard({
<> <>
<div className="p-1 flex flex-col h-full gap-2"> <div className="p-1 flex flex-col h-full gap-2">
<Link <Link
className="flex-1 text-liquid-100 after:content-[''] after:absolute after:inset-0"
to={`${paths.FILE}/${encodeURIComponent(project.path)}`} to={`${paths.FILE}/${encodeURIComponent(project.path)}`}
className="flex-1 text-liquid-100"
> >
{project.name?.replace(FILE_EXT, '')} {project.name?.replace(FILE_EXT, '')}
</Link> </Link>
@ -106,24 +120,37 @@ function ProjectCard({
<span className="text-chalkboard-60 text-xs"> <span className="text-chalkboard-60 text-xs">
Edited {getDisplayedTime(project.entrypointMetadata.modifiedAt)} Edited {getDisplayedTime(project.entrypointMetadata.modifiedAt)}
</span> </span>
<div className="absolute bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"> <div className="absolute z-10 bottom-2 right-2 flex gap-1 items-center opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faPenAlt, size: 'sm' }} icon={{
onClick={() => setIsEditing(true)} icon: faPenAlt,
className: 'p-1',
iconClassName: 'dark:!text-chalkboard-20',
size: 'xs',
}}
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsEditing(true)
}}
className="!p-0" className="!p-0"
/> />
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon={{
icon: faTrashAlt, icon: faTrashAlt,
size: 'sm', className: 'p-1',
bgClassName: 'bg-destroy-80 hover:bg-destroy-70', size: 'xs',
iconClassName: bgClassName: 'bg-destroy-80',
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10', iconClassName: 'text-destroy-20 dark:text-destroy-40',
}} }}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40" className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={() => setIsConfirmingDelete(true)} onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsConfirmingDelete(true)
}}
/> />
</div> </div>
</div> </div>
@ -156,6 +183,8 @@ function ProjectCard({
icon={{ icon={{
icon: faTrashAlt, icon: faTrashAlt,
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
className: 'p-1',
size: 'sm',
iconClassName: iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10', 'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
}} }}

View File

@ -21,7 +21,7 @@ const ProjectSidebarMenu = ({
return renderAsLink ? ( return renderAsLink ? (
<Link <Link
to={paths.HOME} to={paths.HOME}
className="h-9 max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50" className="rounded-sm h-9 mr-auto max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50 dark:hover:bg-chalkboard-90"
data-testid="project-sidebar-link" data-testid="project-sidebar-link"
> >
<img <img
@ -39,7 +39,7 @@ const ProjectSidebarMenu = ({
) : ( ) : (
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button
className="h-9 max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50" className="rounded-sm h-9 mr-auto max-h-min min-w-max border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50 dark:hover:bg-chalkboard-90"
data-testid="project-sidebar-toggle" data-testid="project-sidebar-toggle"
> >
<img <img
@ -82,12 +82,12 @@ const ProjectSidebarMenu = ({
as={Fragment} as={Fragment}
> >
<Popover.Panel <Popover.Panel
className="fixed inset-0 right-auto z-30 grid w-64 h-screen max-h-screen grid-cols-1 border rounded-r-lg shadow-md bg-chalkboard-10 dark:bg-chalkboard-100 border-energy-100 dark:border-energy-100/50" className="fixed inset-0 right-auto z-30 grid w-64 h-screen max-h-screen grid-cols-1 border rounded-r-md shadow-md bg-chalkboard-10 dark:bg-chalkboard-100 border-chalkboard-40 dark:border-chalkboard-80"
style={{ gridTemplateRows: 'auto 1fr auto' }} style={{ gridTemplateRows: 'auto 1fr auto' }}
> >
{({ close }) => ( {({ close }) => (
<> <>
<div className="flex items-center gap-4 px-4 py-3 bg-energy-10/25 dark:bg-energy-110"> <div className="flex items-center gap-4 px-4 py-3">
<img <img
src="/kitt-8bit-winking.svg" src="/kitt-8bit-winking.svg"
alt="KittyCAD App" alt="KittyCAD App"
@ -115,19 +115,16 @@ const ProjectSidebarMenu = ({
{isTauri() ? ( {isTauri() ? (
<FileTree <FileTree
file={file} file={file}
className="overflow-hidden border-0 border-y border-energy-40 dark:border-energy-70" className="overflow-hidden border-0 border-y border-chalkboard-30 dark:border-chalkboard-80"
closePanel={close} closePanel={close}
/> />
) : ( ) : (
<div className="flex-1 overflow-hidden" /> <div className="flex-1 overflow-hidden" />
)} )}
<div className="flex flex-col gap-2 p-4 bg-energy-10/25 dark:bg-energy-110"> <div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
<ExportButton <ExportButton
className={{ className={{
button: button: 'border-transparent dark:border-transparent',
'border-transparent dark:border-transparent hover:border-energy-60',
icon: 'text-energy-10 dark:text-energy-120',
bg: 'bg-energy-120 dark:bg-energy-10',
}} }}
> >
Export Model Export Model
@ -138,10 +135,10 @@ const ProjectSidebarMenu = ({
to={paths.HOME} to={paths.HOME}
icon={{ icon={{
icon: faHome, icon: faHome,
iconClassName: 'text-energy-10 dark:text-energy-120', className: 'p-1',
bgClassName: 'bg-energy-120 dark:bg-energy-10', size: 'sm',
}} }}
className="border-transparent dark:border-transparent hover:border-energy-60" className="border-transparent dark:border-transparent hover:bg-energy-10/20 dark:hover:bg-chalkboard-90"
> >
Go to Home Go to Home
</ActionButton> </ActionButton>

View File

@ -1,11 +1,6 @@
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { import { faBars, faBug, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
faBars,
faBug,
faGear,
faSignOutAlt,
} from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons' import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useState } from 'react' import { Fragment, useState } from 'react'
@ -43,14 +38,14 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<Popover className="relative"> <Popover className="relative">
{user?.image && !imageLoadFailed ? ( {user?.image && !imageLoadFailed ? (
<Popover.Button <Popover.Button
className="border-0 rounded-full w-fit min-w-max p-0 focus:outline-none group" className="border-0 rounded-full w-fit min-w-max p-0 group"
data-testid="user-sidebar-toggle" data-testid="user-sidebar-toggle"
> >
<div className="rounded-full border border-chalkboard-70/50 hover:border-liquid-50 group-focus:border-liquid-50 overflow-hidden"> <div className="rounded-full border overflow-hidden">
<img <img
src={user?.image || ''} src={user?.image || ''}
alt={user?.name || ''} alt={user?.name || ''}
className="h-8 w-8" className="h-8 w-8 rounded-full"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)} onError={() => setImageLoadFailed(true)}
/> />
@ -87,11 +82,11 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
leaveTo="opacity-0 translate-x-4" leaveTo="opacity-0 translate-x-4"
as={Fragment} as={Fragment}
> >
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 dark:border-liquid-100/50 shadow-md rounded-l-lg overflow-hidden"> <Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-90 border border-chalkboard-30 dark:border-chalkboard-80 shadow-md rounded-l-md overflow-hidden">
{({ close }) => ( {({ close }) => (
<> <>
{user && ( {user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100"> <div className="flex items-center gap-4 px-4 py-3 bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-b border-b-chalkboard-30 dark:border-b-chalkboard-80">
{user.image && !imageLoadFailed && ( {user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden"> <div className="rounded-full shadow-inner overflow-hidden">
<img <img
@ -105,15 +100,12 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
)} )}
<div> <div>
<p <p className="m-0 text-mono" data-testid="username">
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''} {displayedName || ''}
</p> </p>
{displayedName !== user.email && ( {displayedName !== user.email && (
<p <p
className="m-0 text-liquid-40 text-xs" className="m-0 text-chalkboard-70 dark:text-chalkboard-40 text-xs"
data-testid="email" data-testid="email"
> >
{user.email} {user.email}
@ -125,8 +117,8 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<div className="p-4 flex flex-col gap-2"> <div className="p-4 flex flex-col gap-2">
<ActionButton <ActionButton
Element="button" Element="button"
icon={{ icon: faGear }} icon={{ icon: 'gear' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent hover:bg-transparent"
onClick={() => { onClick={() => {
// since /settings is a nested route the sidebar doesn't close // since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it // automatically when navigating to it
@ -142,16 +134,16 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<ActionButton <ActionButton
Element="externalLink" Element="externalLink"
to="https://github.com/KittyCAD/modeling-app/discussions" to="https://github.com/KittyCAD/modeling-app/discussions"
icon={{ icon: faGithub }} icon={{ icon: faGithub, className: 'p-1', size: 'sm' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent"
> >
Request a feature Request a feature
</ActionButton> </ActionButton>
<ActionButton <ActionButton
Element="externalLink" Element="externalLink"
to="https://github.com/KittyCAD/modeling-app/issues/new" to="https://github.com/KittyCAD/modeling-app/issues/new"
icon={{ icon: faBug }} icon={{ icon: faBug, className: 'p-1', size: 'sm' }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60" className="border-transparent dark:border-transparent"
> >
Report a bug Report a bug
</ActionButton> </ActionButton>
@ -160,11 +152,13 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
onClick={() => send('Log out')} onClick={() => send('Log out')}
icon={{ icon={{
icon: faSignOutAlt, icon: faSignOutAlt,
className: 'p-1',
bgClassName: 'bg-destroy-80', bgClassName: 'bg-destroy-80',
size: 'sm',
iconClassName: iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10', 'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}} }}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60" className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60 hover:bg-destroy-10/20 dark:hover:bg-destroy-80/20"
data-testid="user-sidebar-sign-out" data-testid="user-sidebar-sign-out"
> >
Sign out Sign out

View File

@ -1,7 +1,6 @@
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useState } from 'react' import { useState } from 'react'
import { ActionButton } from './ActionButton' import { ActionButton } from './ActionButton'
import { faX } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclSinglton' import { useKclContext } from 'lang/KclSinglton'
export function WasmErrBanner() { export function WasmErrBanner() {
@ -26,7 +25,8 @@ export function WasmErrBanner() {
Element="button" Element="button"
onClick={() => setBannerDismissed(true)} onClick={() => setBannerDismissed(true)}
icon={{ icon={{
icon: faX, icon: 'close',
className: 'p-1',
bgClassName: bgClassName:
'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80', 'bg-warn-70 hover:bg-warn-80 dark:bg-warn-70 dark:hover:bg-warn-80',
iconClassName: iconClassName:

View File

@ -1,12 +1,16 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate' import { AnyStateMachine, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands' import {
Command,
CommandBarConfig,
createMachineCommand,
} from '../lib/commands'
import { useCommandsContext } from './useCommandsContext' import { useCommandsContext } from './useCommandsContext'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> { interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
state: StateFrom<T> state: StateFrom<T>
send: Function send: Function
commandBarMeta?: CommandBarMeta commandBarConfig?: CommandBarConfig<T>
commands: Command[] commands: Command[]
owner: string owner: string
} }
@ -14,7 +18,7 @@ interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
export default function useStateMachineCommands<T extends AnyStateMachine>({ export default function useStateMachineCommands<T extends AnyStateMachine>({
state, state,
send, send,
commandBarMeta, commandBarConfig,
owner, owner,
}: UseStateMachineCommandsArgs<T>) { }: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useCommandsContext() const { addCommands, removeCommands } = useCommandsContext()
@ -27,11 +31,11 @@ export default function useStateMachineCommands<T extends AnyStateMachine>({
type, type,
state, state,
send, send,
commandBarMeta, commandBarConfig,
owner, owner,
}) })
) )
.filter((c) => c !== null) as Command[] .filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
addCommands(newCommands) addCommands(newCommands)

View File

@ -57,27 +57,35 @@ select {
} }
button { button {
@apply border border-chalkboard-100 m-0.5 px-3 rounded text-xs; @apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs;
}
button:hover {
@apply border-chalkboard-40 bg-energy-10/20;
} }
.dark button { .dark button {
@apply border-chalkboard-20 hover:border-chalkboard-10 hover:bg-chalkboard-90; @apply border-chalkboard-70;
}
.dark button:hover {
@apply border-chalkboard-60;
} }
button:disabled { button:disabled {
@apply bg-chalkboard-20 text-chalkboard-60 border-chalkboard-20; @apply cursor-not-allowed bg-chalkboard-20 text-chalkboard-60 border-chalkboard-20;
} }
.dark button:disabled { .dark button:disabled {
@apply bg-chalkboard-90 text-chalkboard-40 border-chalkboard-70; @apply bg-chalkboard-90 text-chalkboard-40 border-chalkboard-70;
} }
a { a:not(.action-button) {
@apply text-liquid-80 hover:text-liquid-70; @apply text-energy-70 hover:text-energy-60;
} }
.dark a { .dark a:not(.action-button) {
@apply text-liquid-20 hover:text-liquid-10; @apply text-chalkboard-20 hover:text-energy-10;
} }
.mono { .mono {

View File

@ -4,8 +4,6 @@ import reportWebVitals from './reportWebVitals'
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'
import { Router } from './Router' import { Router } from './Router'
import { HotkeysProvider } from 'react-hotkeys-hook' import { HotkeysProvider } from 'react-hotkeys-hook'
import { inspect } from '@xstate/inspect'
import { DEV } from 'env'
// uncomment for xstate inspector // uncomment for xstate inspector
// if (DEV) // if (DEV)
@ -19,10 +17,20 @@ root.render(
<HotkeysProvider> <HotkeysProvider>
<Router /> <Router />
<Toaster <Toaster
position="bottom-center" position="top-center"
toastOptions={{ toastOptions={{
style: {
borderRadius: '0.25rem',
},
className: className:
'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10', 'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50',
success: {
iconTheme: {
primary: 'oklch(93.31% 0.227 122.3deg)',
secondary: 'oklch(24.49% 0.01405 158.7deg)',
},
duration: 1500,
},
}} }}
/> />
</HotkeysProvider> </HotkeysProvider>

View File

@ -1,117 +1,110 @@
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate' import { AnyStateMachine, ContextFrom, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { CustomIconName } from 'components/CustomIcon'
type InitialCommandBarMetaArg = { type Icon = CustomIconName
name: string
type: 'string' | 'select'
description?: string
defaultValue?: string
options: string | Array<{ name: string }>
}
type Platform = 'both' | 'web' | 'desktop' type Platform = 'both' | 'web' | 'desktop'
type InputType = 'select' | 'string' | 'interaction'
export type CommandArgumentOption = { name: string; isCurrent?: boolean }
export type CommandBarMeta = { // Command arguments can either be defined manually
[key: string]: // or flagged as needing to be looked up from the context.
// This is useful for things like settings, where
// we want to show the current setting value as the default.
// The lookup is done in createMachineCommand.
type CommandArgumentConfig<T extends AnyStateMachine> = {
name: string // TODO: I would love for this to be strongly-typed so we could guarantee it's a valid data payload key on the event type.
type: InputType
description?: string
} & (
| { | {
displayValue: (args: string[]) => string type: 'select'
args: InitialCommandBarMetaArg[] options?: CommandArgumentOption[]
getOptionsFromContext?: keyof ContextFrom<T>
defaultValue?: string
getDefaultValueFromContext?: keyof ContextFrom<T>
}
| {
type: 'string'
defaultValue?: string
getDefaultValueFromContext?: keyof ContextFrom<T>
}
| { type: 'interaction' }
)
export type CommandBarConfig<T extends AnyStateMachine> = Partial<{
[EventType in EventFrom<T>['type']]:
| {
args: CommandArgumentConfig<T>[]
formatFunction?: (args: string[]) => string
icon?: Icon
hide?: Platform hide?: Platform
} }
| { | {
hide?: Platform hide?: Platform
} }
} }>
export type Command = { export type Command = {
owner: string owner: string
name: string name: string
callback: Function callback: Function
meta?: { icon?: Icon
displayValue(args: string[]): string | string args?: CommandArgument[]
args: SubCommand[] formatFunction?: (args: string[]) => string
}
} }
export type SubCommand = { export type CommandArgument = {
name: string name: string
type: 'select' | 'string' defaultValue?: string
description?: string } & (
options?: Partial<{ name: string }>[] | {
type: Extract<InputType, 'select'>
options: CommandArgumentOption[]
} }
| {
type: Exclude<InputType, 'select'>
}
)
interface CommandBarArgs<T extends AnyStateMachine> { interface CreateMachineCommandProps<T extends AnyStateMachine> {
type: EventFrom<T>['type'] type: EventFrom<T>['type']
state: StateFrom<T> state: StateFrom<T>
commandBarMeta?: CommandBarMeta commandBarConfig?: CommandBarConfig<T>
send: Function send: Function
owner: string owner: string
} }
// Creates a command with subcommands, ready for use in the CommandBar component,
// from a more terse Command Bar Meta definition.
export function createMachineCommand<T extends AnyStateMachine>({ export function createMachineCommand<T extends AnyStateMachine>({
type, type,
state, state,
commandBarMeta, commandBarConfig,
send, send,
owner, owner,
}: CommandBarArgs<T>): Command | null { }: CreateMachineCommandProps<T>): Command | null {
const lookedUpMeta = commandBarMeta && commandBarMeta[type] const lookedUpMeta = commandBarConfig && commandBarConfig[type]
if (lookedUpMeta && 'hide' in lookedUpMeta) { if (!lookedUpMeta) return null
// Hide commands based on platform by returning `null`
// so the consumer can filter them out
if ('hide' in lookedUpMeta) {
const { hide } = lookedUpMeta const { hide } = lookedUpMeta
if (hide === 'both') return null if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null else if (hide === 'web' && !isTauri()) return null
} }
let replacedArgs
if (lookedUpMeta && 'args' in lookedUpMeta) { const icon = ('icon' in lookedUpMeta && lookedUpMeta.icon) || undefined
replacedArgs = lookedUpMeta.args.map((arg) => { const formatFunction =
const optionsFromContext = state.context[ ('formatFunction' in lookedUpMeta && lookedUpMeta.formatFunction) ||
arg.options as keyof typeof state.context undefined
] as { name: string }[] | string | undefined
const defaultValueFromContext = state.context[
arg.defaultValue as keyof typeof state.context
] as string | undefined
const options =
arg.options instanceof Array
? arg.options.map((o) => ({
...o,
description:
defaultValueFromContext === o.name ? '(current)' : '',
}))
: !optionsFromContext || typeof optionsFromContext === 'string'
? [
{
name: optionsFromContext,
description: arg.description || '',
},
]
: optionsFromContext.map((o) => ({
name: o.name || '',
description: arg.description || '',
}))
return {
...arg,
options,
}
}) as any[]
}
// We have to recreate this object every time,
// otherwise we'll have stale state in the CommandBar
// after completing our first action
const meta = lookedUpMeta
? {
...lookedUpMeta,
args: replacedArgs,
}
: undefined
return { return {
name: type, name: type,
owner, owner,
icon,
callback: (data: EventFrom<T, typeof type>) => { callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) { if (data !== undefined && data !== null) {
send(type, { data }) send(type, { data })
@ -119,6 +112,66 @@ export function createMachineCommand<T extends AnyStateMachine>({
send(type) send(type)
} }
}, },
meta: meta as any, ...('args' in lookedUpMeta
? {
args: getCommandArgumentValuesFromContext(state, lookedUpMeta.args),
formatFunction,
}
: {}),
} }
} }
function getCommandArgumentValuesFromContext<T extends AnyStateMachine>(
state: StateFrom<T>,
args: CommandArgumentConfig<T>[]
): CommandArgument[] {
function getDefaultValue(
arg: CommandArgumentConfig<T> & { type: 'string' | 'select' }
) {
if (
arg.type === 'select' ||
('getDefaultValueFromContext' in arg && arg.getDefaultValueFromContext)
) {
return state.context[arg.getDefaultValueFromContext]
} else {
return arg.defaultValue
}
}
return args.map((arg) => {
switch (arg.type) {
case 'interaction':
return {
name: arg.name,
type: 'interaction',
}
case 'string':
return {
name: arg.name,
type: arg.type,
defaultValue: arg.getDefaultValueFromContext
? state.context[arg.getDefaultValueFromContext]
: arg.defaultValue,
}
default:
return {
name: arg.name,
type: arg.type,
defaultValue: getDefaultValue(arg),
options: arg.getOptionsFromContext
? state.context[arg.getOptionsFromContext].map(
(v: string | { name: string }) => ({
name: typeof v === 'string' ? v : v.name,
isCurrent: v === getDefaultValue(arg),
})
)
: arg.getDefaultValueFromContext
? arg.options?.map((v) => ({
...v,
isCurrent: v.name === getDefaultValue(arg),
}))
: arg.options,
}
}
})
}

View File

@ -1,7 +1,7 @@
import { import {
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
faCircleDot, faCircle,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
@ -13,7 +13,7 @@ export function getSortIcon(currentSort: string, newSort: string) {
} else if (currentSort === newSort + DESC) { } else if (currentSort === newSort + DESC) {
return faArrowDown return faArrowDown
} }
return faCircleDot return faCircle
} }
export function getNextSearchParams(currentSort: string, newSort: string) { export function getNextSearchParams(currentSort: string, newSort: string) {

View File

@ -1,7 +1,7 @@
import { createMachine, assign } from 'xstate' import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL' import withBaseURL from '../lib/withBaseURL'
import { CommandBarMeta } from '../lib/commands' import { CommandBarConfig } from '../lib/commands'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { invoke } from '@tauri-apps/api' import { invoke } from '@tauri-apps/api'
import { VITE_KC_API_BASE_URL } from 'env' import { VITE_KC_API_BASE_URL } from 'env'
@ -40,10 +40,14 @@ export type Events =
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY' export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || '' const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarMeta: CommandBarMeta = { export const authCommandBarConfig: CommandBarConfig<typeof authMachine> = {
'Log in': { 'Log in': {
hide: 'both', hide: 'both',
}, },
'Log out': {
args: [],
icon: 'arrowLeft',
},
} }
export const authMachine = createMachine<UserContext, Events>( export const authMachine = createMachine<UserContext, Events>(

View File

@ -1,59 +1,55 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router' import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarMeta } from '../lib/commands' import { CommandBarConfig } from '../lib/commands'
export const homeCommandMeta: CommandBarMeta = { export const homeCommandConfig: CommandBarConfig<typeof homeMachine> = {
'Create project': { 'Create project': {
displayValue: (args: string[]) => `Create project "${args[0]}"`, icon: 'folderPlus',
args: [ args: [
{ {
name: 'name', name: 'name',
type: 'string', type: 'string',
description: '(default)', getDefaultValueFromContext: 'defaultProjectName',
options: 'defaultProjectName',
}, },
], ],
}, },
'Open project': { 'Open project': {
displayValue: (args: string[]) => `Open project "${args[0]}"`, icon: 'arrowRight',
args: [ args: [
{ {
name: 'name', name: 'name',
type: 'select', type: 'select',
options: 'projects', getOptionsFromContext: 'projects',
}, },
], ],
}, },
'Delete project': { 'Delete project': {
displayValue: (args: string[]) => `Delete project "${args[0]}"`, icon: 'close',
args: [ args: [
{ {
name: 'name', name: 'name',
type: 'select', type: 'select',
options: 'projects', getOptionsFromContext: 'projects',
}, },
], ],
}, },
'Rename project': { 'Rename project': {
displayValue: (args: string[]) => icon: 'folder',
formatFunction: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`, `Rename project "${args[0]}" to "${args[1]}"`,
args: [ args: [
{ {
name: 'oldName', name: 'oldName',
type: 'select', type: 'select',
options: 'projects', getOptionsFromContext: 'projects',
}, },
{ {
name: 'newName', name: 'newName',
type: 'string', type: 'string',
description: '(default)', getDefaultValueFromContext: 'defaultProjectName',
options: 'defaultProjectName',
}, },
], ],
}, },
assign: {
hide: 'both',
},
} }
export const homeMachine = createMachine( export const homeMachine = createMachine(

View File

@ -1,5 +1,5 @@
import { assign, createMachine } from 'xstate' import { assign, createMachine } from 'xstate'
import { CommandBarMeta } from '../lib/commands' import { CommandBarConfig } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme' import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
import { CameraSystem, cameraSystems } from 'lib/cameraControls' import { CameraSystem, cameraSystems } from 'lib/cameraControls'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
@ -24,25 +24,27 @@ export type Toggle = 'On' | 'Off'
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY' export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarMeta: CommandBarMeta = { export const settingsCommandBarConfig: CommandBarConfig<
typeof settingsMachine
> = {
'Set Base Unit': { 'Set Base Unit': {
displayValue: (args: string[]) => 'Set your default base unit', icon: 'gear',
args: [ args: [
{ {
name: 'baseUnit', name: 'baseUnit',
type: 'select', type: 'select',
defaultValue: 'baseUnit', getDefaultValueFromContext: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })), options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
}, },
], ],
}, },
'Set Camera Controls': { 'Set Camera Controls': {
displayValue: (args: string[]) => 'Set your camera controls', icon: 'gear',
args: [ args: [
{ {
name: 'cameraControls', name: 'cameraControls',
type: 'select', type: 'select',
defaultValue: 'cameraControls', getDefaultValueFromContext: 'cameraControls',
options: Object.values(cameraSystems).map((v) => ({ name: v })), options: Object.values(cameraSystems).map((v) => ({ name: v })),
}, },
], ],
@ -51,15 +53,13 @@ export const settingsCommandBarMeta: CommandBarMeta = {
hide: 'both', hide: 'both',
}, },
'Set Default Project Name': { 'Set Default Project Name': {
displayValue: (args: string[]) => 'Set a new default project name', icon: 'gear',
hide: 'web', hide: 'web',
args: [ args: [
{ {
name: 'defaultProjectName', name: 'defaultProjectName',
type: 'string', type: 'string',
description: '(default)', getDefaultValueFromContext: 'defaultProjectName',
defaultValue: 'defaultProjectName',
options: 'defaultProjectName',
}, },
], ],
}, },
@ -67,23 +67,23 @@ export const settingsCommandBarMeta: CommandBarMeta = {
hide: 'both', hide: 'both',
}, },
'Set Text Wrapping': { 'Set Text Wrapping': {
displayValue: (args: string[]) => 'Set whether text in the editor wraps', icon: 'gear',
args: [ args: [
{ {
name: 'textWrapping', name: 'textWrapping',
type: 'select', type: 'select',
defaultValue: 'textWrapping', getDefaultValueFromContext: 'textWrapping',
options: [{ name: 'On' }, { name: 'Off' }], options: [{ name: 'On' }, { name: 'Off' }],
}, },
], ],
}, },
'Set Theme': { 'Set Theme': {
displayValue: (args: string[]) => 'Change the app theme', icon: 'gear',
args: [ args: [
{ {
name: 'theme', name: 'theme',
type: 'select', type: 'select',
defaultValue: 'theme', getDefaultValueFromContext: 'theme',
options: Object.values(Themes).map((v): { name: string } => ({ options: Object.values(Themes).map((v): { name: string } => ({
name: v, name: v,
})), })),
@ -91,12 +91,12 @@ export const settingsCommandBarMeta: CommandBarMeta = {
], ],
}, },
'Set Unit System': { 'Set Unit System': {
displayValue: (args: string[]) => 'Set your default unit system', icon: 'gear',
args: [ args: [
{ {
name: 'unitSystem', name: 'unitSystem',
type: 'select', type: 'select',
defaultValue: 'unitSystem', getDefaultValueFromContext: 'unitSystem',
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }], options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
}, },
], ],
@ -126,7 +126,12 @@ export const settingsMachine = createMachine(
on: { on: {
'Set Base Unit': { 'Set Base Unit': {
actions: [ actions: [
assign({ baseUnit: (_, event) => event.data.baseUnit }), assign({
baseUnit: (_, event) => {
console.log('event', event)
return event.data.baseUnit
},
}),
'persistSettings', 'persistSettings',
'toastSuccess', 'toastSuccess',
], ],

View File

@ -17,7 +17,7 @@ import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router' import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading' import Loading from '../components/Loading'
import { useMachine } from '@xstate/react' import { useMachine } from '@xstate/react'
import { homeCommandMeta, homeMachine } from '../machines/homeMachine' import { homeCommandConfig, homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate' import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router' import { paths } from '../Router'
import { import {
@ -147,7 +147,7 @@ const Home = () => {
commands, commands,
send, send,
state, state,
commandBarMeta: homeCommandMeta, commandBarConfig: homeCommandConfig,
owner: 'home', owner: 'home',
}) })
@ -178,23 +178,24 @@ const Home = () => {
<div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0"> <div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0">
<section className="flex justify-between"> <section className="flex justify-between">
<h1 className="text-3xl text-bold">Your Projects</h1> <h1 className="text-3xl text-bold">Your Projects</h1>
<div className="flex"> <div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton <ActionButton
Element="button" Element="button"
className={ className={
!sort.includes('name') 'text-sm ' +
(!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40' ? 'text-chalkboard-80 dark:text-chalkboard-40'
: '' : '')
} }
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))} onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
icon={{ icon={{
icon: getSortIcon(sort, 'name'), icon: getSortIcon(sort, 'name'),
bgClassName: !sort?.includes('name') className: 'p-1.5',
? 'bg-liquid-50 dark:bg-liquid-70' iconClassName: !sort.includes('name')
: '', ? '!text-chalkboard-40'
iconClassName: !sort?.includes('name')
? 'text-liquid-80 dark:text-liquid-30'
: '', : '',
size: 'sm',
}} }}
> >
Name Name
@ -202,21 +203,19 @@ const Home = () => {
<ActionButton <ActionButton
Element="button" Element="button"
className={ className={
!isSortByModified 'text-sm ' +
(!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40' ? 'text-chalkboard-80 dark:text-chalkboard-40'
: '' : '')
} }
onClick={() => onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified')) setSearchParams(getNextSearchParams(sort, 'modified'))
} }
icon={{ icon={{
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown, icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
bgClassName: !isSortByModified className: 'p-1.5',
? 'bg-liquid-50 dark:bg-liquid-70' iconClassName: !isSortByModified ? '!text-chalkboard-40' : '',
: '', size: 'sm',
iconClassName: !isSortByModified
? 'text-liquid-80 dark:text-liquid-30'
: '',
}} }}
> >
Last Modified Last Modified
@ -225,11 +224,15 @@ const Home = () => {
</section> </section>
<section> <section>
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30"> <p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '} Loaded from{' '}
<code className="text-liquid-80 dark:text-liquid-30"> <span className="text-energy-70 dark:text-energy-40">
{defaultDirectory} {defaultDirectory}
</code> </span>
, which you can change in your <Link to="settings">Settings</Link>. .{' '}
<Link to="settings" className="underline underline-offset-2">
Edit in settings
</Link>
.
</p> </p>
{state.matches('Reading projects') ? ( {state.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading> <Loading>Loading your Projects...</Loading>
@ -254,7 +257,7 @@ const Home = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={() => send('Create project')} onClick={() => send('Create project')}
icon={{ icon: faPlus }} icon={{ icon: faPlus, iconClassName: 'p-1 w-4' }}
data-testid="home-new-file" data-testid="home-new-file"
> >
New file New file

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { SettingsSection } from 'routes/Settings' import { SettingsSection } from 'routes/Settings'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext' import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
@ -70,28 +68,11 @@ export default function Units() {
</li> </li>
</ul> </ul>
</SettingsSection> </SettingsSection>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Streaming"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Streaming
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { Platform, platform } from '@tauri-apps/api/os' import { Platform, platform } from '@tauri-apps/api/os'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -17,7 +15,7 @@ export default function CmdK() {
async function getPlatform() { async function getPlatform() {
setPlatformName(await platform()) setPlatformName(await platform())
} }
getPlatform() void getPlatform()
}, [setPlatformName]) }, [setPlatformName])
return ( return (
@ -57,28 +55,11 @@ export default function CmdK() {
management from the command bar, but we will be powering modeling management from the command bar, but we will be powering modeling
commands with it soon. commands with it soon.
</p> </p>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: User Menu"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: User Menu
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
@ -57,28 +55,11 @@ export default function CodeEditor() {
<kbd>Shift</kbd> + <kbd>C</kbd>. <kbd>Shift</kbd> + <kbd>C</kbd>.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Parametric Modeling"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Parametric Modeling
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function Export() { export default function Export() {
@ -44,29 +42,11 @@ export default function Export() {
export to almost any CAD software. export to almost any CAD software.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton next={next}
Element="button" dismiss={dismiss}
onClick={dismiss} nextText="Next: Sketching"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
data-testid="onboarding-next"
>
Next: Sketching
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, useDismiss } from '.'
import { ActionButton } from '../../components/ActionButton'
import { useDismiss } from '.'
import { useEffect } from 'react' import { useEffect } from 'react'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
import { kclManager } from 'lang/KclSinglton' import { kclManager } from 'lang/KclSinglton'
@ -38,28 +36,12 @@ export default function FutureWork() {
hardware design with us 💚. hardware design with us 💚.
</p> </p>
<p className="my-4"> The KittyCAD Team</p> <p className="my-4"> The KittyCAD Team</p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={dismiss}
icon={{ nextText="Finish"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={dismiss}
icon={{ icon: faArrowRight }}
>
Finish
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
@ -97,28 +95,11 @@ export default function InteractiveNumbers() {
we'd love to hear your ideas for how to make it better. we'd love to hear your ideas for how to make it better.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Command Bar"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Command Bar
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,6 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../../components/ActionButton'
import { import {
ONBOARDING_PROJECT_NAME, ONBOARDING_PROJECT_NAME,
OnboardingButtons,
onboardingPaths, onboardingPaths,
useDismiss, useDismiss,
useNextClick, useNextClick,
@ -65,31 +64,15 @@ function OnboardingWithNewFile() {
We see you have some of your own code written in this project. We see you have some of your own code written in this project.
Please save it somewhere else before continuing the onboarding. Please save it somewhere else before continuing the onboarding.
</p> </p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={() => {
icon={{
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
kclManager.setCodeAndExecute(bracket) kclManager.setCodeAndExecute(bracket)
next() next()
}} }}
icon={{ icon: faArrowRight }} nextText="Overwrite code and continue"
> />
Overwrite code and continue
</ActionButton>
</div>
</> </>
) : ( ) : (
<> <>
@ -103,32 +86,16 @@ function OnboardingWithNewFile() {
click the button below. click the button below.
</p> </p>
</section> </section>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={() => {
icon={{ void createAndOpenNewProject()
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={() => {
createAndOpenNewProject()
kclManager.setCode(bracket, false) kclManager.setCode(bracket, false)
dismiss() dismiss()
}} }}
icon={{ icon: faArrowRight }} nextText="Make a new project"
> />
Make a new project
</ActionButton>
</div>
</> </>
)} )}
</div> </div>
@ -192,28 +159,12 @@ export default function Introduction() {
release as early as possible to get feedback from users like you. release as early as possible to get feedback from users like you.
</p> </p>
</section> </section>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" dismiss={dismiss}
onClick={dismiss} next={next}
icon={{ nextText="Camera"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Get Started
</ActionButton>
</div>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { useBackdropHighlight } from 'hooks/useBackdropHighlight' import { useBackdropHighlight } from 'hooks/useBackdropHighlight'
import { Themes, getSystemTheme } from 'lib/theme' import { Themes, getSystemTheme } from 'lib/theme'
@ -57,28 +55,11 @@ export default function ParametricModeling() {
on the width of the bracket to meet a set safety factor on line 6. on the width of the bracket to meet a set safety factor on line 6.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Interactive Numbers"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Interactive Numbers
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
@ -28,28 +26,11 @@ export default function ProjectMenu() {
we add support for multi-file assemblies. we add support for multi-file assemblies.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton next={next}
Element="button" dismiss={dismiss}
onClick={dismiss} nextText="Next: Export"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Export
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { useEffect } from 'react' import { useEffect } from 'react'
import { kclManager } from 'lang/KclSinglton' import { kclManager } from 'lang/KclSinglton'
@ -39,29 +37,12 @@ export default function Sketching() {
Watch the code pane as you click. Point-and-click interactions are Watch the code pane as you click. Point-and-click interactions are
always just modifying and generating code in KittyCAD Modeling App. always just modifying and generating code in KittyCAD Modeling App.
</p> </p>
<div className="flex justify-between mt-6"> <OnboardingButtons
<ActionButton className="mt-6"
Element="button" next={next}
onClick={dismiss} dismiss={dismiss}
icon={{ nextText="Next: Future Work"
icon: faXmark, />
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
data-testid="onboarding-next"
>
Next: Future Work
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function Streaming() { export default function Streaming() {
@ -38,28 +36,11 @@ export default function Streaming() {
and you won't have to worry about the performance of the device. and you won't have to worry about the performance of the device.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Code Editor"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Code Editing
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -1,6 +1,4 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons' import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
import { ActionButton } from '../../components/ActionButton'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useStore } from '../../useStore' import { useStore } from '../../useStore'
export default function UserMenu() { export default function UserMenu() {
@ -25,28 +23,11 @@ export default function UserMenu() {
change your settings, sign out, or request a feature. change your settings, sign out, or request a feature.
</p> </p>
</section> </section>
<div className="flex justify-between"> <OnboardingButtons
<ActionButton dismiss={dismiss}
Element="button" next={next}
onClick={dismiss} nextText="Next: Project Menu"
icon={{ />
icon: faXmark,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Project Menu
</ActionButton>
</div>
</div> </div>
</div> </div>
) )

View File

@ -17,6 +17,7 @@ import Export from './Export'
import FutureWork from './FutureWork' import FutureWork from './FutureWork'
import { paths } from 'Router' import { paths } from 'Router'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { ActionButton } from 'components/ActionButton'
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn' export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
@ -120,6 +121,45 @@ export function useDismiss() {
}, [send, navigate, filePath]) }, [send, navigate, filePath])
} }
export function OnboardingButtons({
next,
nextText,
dismiss,
className,
...props
}: {
next: () => void
nextText?: string
dismiss: () => void
className?: string
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={'flex justify-between ' + (className ?? '')} {...props}>
<ActionButton
Element="button"
onClick={dismiss}
icon={{
icon: 'close',
bgClassName: 'bg-destroy-80',
iconClassName: 'text-destroy-20 group-hover:text-destroy-10',
}}
className="hover:border-destroy-40 hover:bg-destroy-10/50 dark:hover:bg-destroy-80/50"
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
onClick={next}
icon={{ icon: 'arrowRight', bgClassName: 'dark:bg-chalkboard-80' }}
className="dark:hover:bg-chalkboard-80/50"
data-testid="onboarding-next"
>
{nextText ?? 'Next'}
</ActionButton>
</div>
)
}
const Onboarding = () => { const Onboarding = () => {
const dismiss = useDismiss() const dismiss = useDismiss()
useHotkeys('esc', dismiss) useHotkeys('esc', dismiss)

View File

@ -1,8 +1,4 @@
import { import { faArrowRotateBack, faXmark } from '@fortawesome/free-solid-svg-icons'
faArrowRotateBack,
faFolder,
faXmark,
} from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton' import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader' import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog' import { open } from '@tauri-apps/api/dialog'
@ -185,14 +181,9 @@ export const Settings = () => {
/> />
<ActionButton <ActionButton
Element="button" Element="button"
className="bg-chalkboard-100 dark:bg-chalkboard-90 hover:bg-chalkboard-90 dark:hover:bg-chalkboard-80 !text-chalkboard-10 border-chalkboard-100 hover:border-chalkboard-70"
onClick={handleDirectorySelection} onClick={handleDirectorySelection}
icon={{ icon={{
icon: faFolder, icon: 'folder',
bgClassName:
'bg-liquid-20 group-hover:bg-liquid-10 hover:bg-liquid-10',
iconClassName:
'text-liquid-90 group-hover:text-liquid-90 hover:text-liquid-90',
}} }}
> >
Choose a folder Choose a folder
@ -305,7 +296,7 @@ export const Settings = () => {
<ActionButton <ActionButton
Element="button" Element="button"
onClick={restartOnboarding} onClick={restartOnboarding}
icon={{ icon: faArrowRotateBack }} icon={{ icon: faArrowRotateBack, size: 'sm', className: 'p-1' }}
> >
Replay Onboarding Replay Onboarding
</ActionButton> </ActionButton>

View File

@ -1,3 +1,5 @@
const plugin = require('tailwindcss/plugin')
const themeColorRamps = [ const themeColorRamps = [
{ name: 'chalkboard', stops: 12 }, { name: 'chalkboard', stops: 12 },
{ name: 'energy', stops: 12 }, { name: 'energy', stops: 12 },
@ -10,7 +12,7 @@ const themeColorRamps = [
{ name: 'warn', stops: 8 }, { name: 'warn', stops: 8 },
{ name: 'succeed', stops: 8 }, { name: 'succeed', stops: 8 },
] ]
const toOKLCHVar = val => `oklch(var(${val}) / <alpha-value>) ` const toOKLCHVar = (val) => `oklch(var(${val}) / <alpha-value>) `
const themeColors = Object.fromEntries( const themeColors = Object.fromEntries(
themeColorRamps.map(({ name, stops }) => [ themeColorRamps.map(({ name, stops }) => [
@ -18,19 +20,15 @@ const themeColors = Object.fromEntries(
Object.fromEntries( Object.fromEntries(
new Array(stops) new Array(stops)
.fill(0) .fill(0)
.map((_, i) => [ .map((_, i) => [(i + 1) * 10, toOKLCHVar(`--_${name}-${(i + 1) * 10}`)])
(i + 1) * 10,
toOKLCHVar(`--_${name}-${(i + 1) * 10}`),
])
), ),
]) ])
) )
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ mode: 'jit',
"./src/**/*.{js,jsx,ts,tsx}", content: ['./src/**/*.{js,jsx,ts,tsx}'],
],
theme: { theme: {
extend: { extend: {
colors: { colors: {
@ -41,5 +39,22 @@ module.exports = {
darkMode: 'class', darkMode: 'class',
plugins: [ plugins: [
require('@headlessui/tailwindcss'), require('@headlessui/tailwindcss'),
// custom plugin to add variants for aria-pressed
// To use, just add a class of 'group-pressed:<some-tailwind-class>' or 'pressed:<some-tailwind-class>'
// to your element. Based on https://dev.to/philw_/tying-tailwind-styling-to-aria-attributes-502f
plugin(function ({ addVariant, e }) {
addVariant('group-pressed', ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.group[aria-pressed='true'] .${e(
`group-pressed${separator}${className}`
)}`
})
})
addVariant('pressed', ({ modifySelectors, separator }) => {
modifySelectors(({ className }) => {
return `.${e(`pressed${separator}${className}`)}[aria-pressed='true']`
})
})
}),
], ],
} }