[Website] Make product stepper visuals interactive. (#20602)

We had low-res screenshots for each step in the stepper. Replaced them
with interactive components.


https://github.com/user-attachments/assets/d03ff924-a1dd-467f-ba19-cece0ecb3486
This commit is contained in:
Abdullah.
2026-05-15 13:58:22 +05:00
committed by GitHub
parent 45bea6f991
commit d94d2eb67c
21 changed files with 2907 additions and 36 deletions

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -29,6 +29,9 @@ import {
ProductStepper,
type ProductStepperStepType,
} from '@/sections/ProductStepper';
import { DataModelVisual } from '@/sections/ProductStepper/visuals/DataModelVisual';
import { WorkflowVisual } from '@/sections/ProductStepper/visuals/WorkflowVisual';
import { LayoutVisual } from '@/sections/ProductStepper/visuals/LayoutVisual';
import { Tabs } from '@/sections/Tabs';
import { ThreeCards } from '@/sections/ThreeCards';
import { theme } from '@/theme';
@@ -58,10 +61,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<HeadingPart fontFamily="sans">{i18n._(msg`Data model`)}</HeadingPart>
),
body: msg`Add objects and fields`,
image: {
src: '/images/product/stepper/step-one.webp',
alt: 'Twenty data model: add objects and fields',
},
visual: DataModelVisual,
},
{
icon: 'check',
@@ -69,10 +69,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<HeadingPart fontFamily="sans">{i18n._(msg`Automation`)}</HeadingPart>
),
body: msg`Create a workflow`,
image: {
src: '/images/product/stepper/step-two.webp',
alt: 'Twenty automation: create a workflow',
},
visual: WorkflowVisual,
},
{
icon: 'eye',
@@ -80,10 +77,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<HeadingPart fontFamily="sans">{i18n._(msg`Layout`)}</HeadingPart>
),
body: msg`Tailor record pages, menus, and views`,
image: {
src: '/images/product/stepper/step-three.webp',
alt: 'Twenty layout: record pages, menus, and views',
},
visual: LayoutVisual,
},
];

View File

@@ -66,7 +66,7 @@ export function Flow({ body, children, eyebrow, steps }: FlowProps) {
}),
);
const images = steps.map((step) => step.image);
const stepVisuals = steps.map(({ visual }) => ({ visual }));
return (
<StyledSection ref={scrollContainerRef}>
@@ -86,7 +86,7 @@ export function Flow({ body, children, eyebrow, steps }: FlowProps) {
onMobileStepIndexChange={setMobileStepIndex}
steps={contentSteps}
/>
<Visual activeStepIndex={activeStepIndex} images={images} />
<Visual activeStepIndex={activeStepIndex} stepVisuals={stepVisuals} />
</Grid>
</StyledSection>
);

View File

@@ -1,13 +1,20 @@
import type { ImageType } from '@/design-system/components/Image';
'use client';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import type { ComponentType } from 'react';
import type { StepperVisualProps } from './types';
import { StepperVisualFrame } from './StepperVisualFrame';
type StepVisual = {
visual: ComponentType<StepperVisualProps>;
};
type ProductStepperVisualProps = {
activeStepIndex: number;
images: ImageType[];
stepVisuals: StepVisual[];
};
const PRODUCT_STEPPER_BACKGROUND = '/images/product/stepper/background.webp';
@@ -41,19 +48,30 @@ const VisualFrame = styled.div`
}
`;
const slideImageClassName = css`
object-fit: contain;
object-position: center;
const slideClassName = css`
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 0.4s ease;
&[data-active='true'] {
opacity: 1;
pointer-events: auto;
}
`;
export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
if (!images || images.length === 0) {
const visualWrapperClassName = css`
display: flex;
height: 100%;
width: 100%;
`;
export function Visual({
activeStepIndex,
stepVisuals,
}: ProductStepperVisualProps) {
if (!stepVisuals || stepVisuals.length === 0) {
return null;
}
@@ -64,19 +82,20 @@ export function Visual({ activeStepIndex, images }: ProductStepperVisualProps) {
backgroundSrc={PRODUCT_STEPPER_BACKGROUND}
shapeSrc={PRODUCT_STEPPER_SHAPE}
>
{images.map((image, index) => {
if (!image) return null;
{stepVisuals.map((step, index) => {
const isActive = index === activeStepIndex;
const VisualComponent = step.visual;
return (
<NextImage
key={`${image.src}-${index}`}
alt={image.alt}
className={slideImageClassName}
data-active={String(index === activeStepIndex)}
fill
sizes="(min-width: 921px) 50vw, 100vw"
src={image.src}
/>
<div
key={index}
className={slideClassName}
data-active={String(isActive)}
>
<div className={visualWrapperClassName}>
<VisualComponent active={isActive} />
</div>
</div>
);
})}
</StepperVisualFrame>

View File

@@ -1,10 +1,13 @@
import type { ImageType } from '@/design-system/components/Image';
import type { MessageDescriptor } from '@lingui/core';
import type { ReactNode } from 'react';
import type { ComponentType, ReactNode } from 'react';
export type StepperVisualProps = {
active: boolean;
};
export type ProductStepperStepType = {
body: MessageDescriptor;
heading: ReactNode;
icon: string;
image: ImageType;
visual: ComponentType<StepperVisualProps>;
};

View File

@@ -0,0 +1,197 @@
'use client';
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import {
STEPPER_BG,
STEPPER_BORDER_MEDIUM,
STEPPER_BORDER_SUBTLE,
STEPPER_FONT,
STEPPER_HEADER_BG,
STEPPER_HEADER_BORDER,
STEPPER_SHADOW_SM,
STEPPER_TEXT,
STEPPER_TEXT_MUTED,
STEPPER_TEXT_TERTIARY,
} from './stepper-visual-tokens';
const Wrapper = styled.div`
background: ${STEPPER_BG};
border-radius: 2px;
box-shadow: ${STEPPER_SHADOW_SM};
display: flex;
flex-direction: column;
font-family: ${STEPPER_FONT};
height: 92%;
margin-left: auto;
margin-top: auto;
overflow: hidden;
width: 88%;
`;
const Header = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 30px;
padding: 0 10px;
`;
const HeaderLogo = styled.span`
align-items: center;
background: ${STEPPER_HEADER_BG};
border: 1px solid ${STEPPER_HEADER_BORDER};
border-radius: 3px;
color: ${STEPPER_TEXT};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const HeaderTitle = styled.span`
color: ${STEPPER_TEXT};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
padding: 0 2px;
`;
const HeaderActions = styled.div`
align-items: center;
display: flex;
gap: 8px;
margin-left: auto;
`;
const HeaderBtn = styled.span`
align-items: center;
border: 1px solid ${STEPPER_BORDER_MEDIUM};
border-radius: 4px;
color: ${STEPPER_TEXT_MUTED};
display: flex;
height: 22px;
justify-content: center;
width: 22px;
`;
const HeaderCmdBtn = styled.span`
align-items: center;
border: 1px solid ${STEPPER_BORDER_SUBTLE};
border-radius: 4px;
color: ${STEPPER_TEXT_TERTIARY};
display: flex;
font-size: 12px;
font-weight: 500;
gap: 4px;
height: 22px;
padding: 0 6px;
`;
export const ShellCanvas = styled.div`
background: white;
border: 1px solid ${STEPPER_BORDER_MEDIUM};
border-radius: 8px;
flex: 1;
margin: 0 10px 10px;
min-height: 0;
overflow: hidden;
position: relative;
user-select: none;
`;
export const ShellSvgLayer = styled.svg`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
`;
type AppPreviewShellProps = {
active: boolean;
children: ReactNode;
title: string;
};
export function AppPreviewShell({
active,
children,
title,
}: AppPreviewShellProps) {
return (
<Wrapper style={{ opacity: active ? 1 : 0.7, transition: 'opacity 0.3s' }}>
<Header>
<HeaderLogo>
<svg
fill="none"
height="9"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="9"
>
<path d="M10 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M19.5 13a2 2 0 1 0 0 4a2 2 0 0 0 0 -4" />
<path d="M4.5 13a2 2 0 1 0 0 4a2 2 0 0 0 0 -4" />
<path d="M12 7v4" />
<path d="M6.5 13l5.5 -2l5.5 2" />
</svg>
</HeaderLogo>
<HeaderTitle>{title}</HeaderTitle>
<HeaderActions>
<HeaderBtn>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="14"
>
<path d="M6 15l6 -6l6 6" />
</svg>
</HeaderBtn>
<HeaderBtn>
<svg
fill="none"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="14"
>
<path d="M6 9l6 6l6 -6" />
</svg>
</HeaderBtn>
<HeaderCmdBtn>
<svg
fill="none"
height="12"
stroke="#666"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="12"
>
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
K
</HeaderCmdBtn>
</HeaderActions>
</Header>
{children}
</Wrapper>
);
}

View File

@@ -0,0 +1,314 @@
'use client';
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import type { StepperVisualProps } from '../types';
import { AppPreviewShell, ShellCanvas, ShellSvgLayer } from './AppPreviewShell';
import {
BADGE_CUSTOM_BG,
BADGE_CUSTOM_BORDER,
BADGE_CUSTOM_TEXT,
BADGE_STANDARD_BG,
BADGE_STANDARD_BORDER,
BADGE_STANDARD_TEXT,
CONNECTIONS,
type ConnectionDef,
ENTITIES,
} from './data/DataModel.data';
import { DrawEdge } from './DrawEdge';
import { IconChevronDown } from './icons/DataModelIcons';
import {
STEPPER_BORDER_LIGHT,
STEPPER_BORDER_MEDIUM,
STEPPER_BORDER_STRONG,
STEPPER_CARD_BG,
STEPPER_TEXT,
STEPPER_TEXT_MUTED,
STEPPER_TEXT_TERTIARY,
} from './stepper-visual-tokens';
const EntityCard = styled.div<{ $hovered: boolean }>`
backdrop-filter: blur(14px);
background: ${STEPPER_CARD_BG};
border: 1px solid
${({ $hovered }) =>
$hovered ? STEPPER_BORDER_STRONG : STEPPER_BORDER_MEDIUM};
border-radius: 8px;
box-shadow:
0 0 2px rgba(0, 0, 0, 0.08),
0 2px 2px rgba(0, 0, 0, 0.04);
cursor: grab;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 130px;
padding: 6px;
position: absolute;
touch-action: none;
transition: border-color 0.15s ease;
z-index: 2;
&:active {
cursor: grabbing;
z-index: 10;
}
`;
const EntityHeader = styled.div`
align-items: center;
display: flex;
gap: 4px;
overflow: hidden;
white-space: nowrap;
`;
const InnerCard = styled.div`
background: white;
border: 1px solid ${STEPPER_BORDER_LIGHT};
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
padding: 4px 0;
`;
const EntityIcon = styled.span<{ $bg: string; $border: string }>`
align-items: center;
background: ${({ $bg }) => $bg};
border: 1px solid ${({ $border }) => $border};
border-radius: 3px;
color: ${STEPPER_TEXT};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const EntityLabel = styled.span`
color: ${STEPPER_TEXT};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
const EntityMeta = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
`;
const MetaBadge = styled.div`
align-items: center;
display: flex;
gap: 4px;
margin-left: auto;
`;
const MetaBadgeIcon = styled.span<{
$bg: string;
$border: string;
$color: string;
}>`
align-items: center;
background: ${({ $bg }) => $bg};
border: 1px solid ${({ $border }) => $border};
border-radius: 2px;
color: ${({ $color }) => $color};
display: flex;
font-size: 8px;
font-weight: 500;
height: 14px;
justify-content: center;
width: 14px;
`;
const MetaBadgeText = styled.span`
color: ${STEPPER_TEXT_MUTED};
font-size: 9px;
font-weight: 400;
`;
const FieldRow = styled.div`
align-items: center;
color: ${STEPPER_TEXT};
display: flex;
font-size: 12px;
font-weight: 400;
gap: 4px;
height: 22px;
line-height: 1.4;
padding: 0 6px;
`;
const FieldIcon = styled.span`
align-items: center;
color: ${STEPPER_TEXT_MUTED};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const ExpandHint = styled.div`
align-items: center;
color: ${STEPPER_TEXT_TERTIARY};
display: flex;
font-size: 12px;
font-weight: 400;
gap: 4px;
height: 22px;
line-height: 1.4;
padding: 0 6px;
`;
const CARD_WIDTH = 140;
const CARD_HEIGHT_ESTIMATE = 110;
function getCardCenter(
positions: Record<string, { x: number; y: number }>,
entityId: string,
): { x: number; y: number } {
const pos = positions[entityId];
if (!pos) return { x: 0, y: 0 };
return { x: pos.x + CARD_WIDTH / 2, y: pos.y + CARD_HEIGHT_ESTIMATE / 2 };
}
export function DataModelVisual({ active }: StepperVisualProps) {
const [positions, setPositions] = useState<
Record<string, { x: number; y: number }>
>(() =>
Object.fromEntries(
ENTITIES.map((entity) => [entity.id, { x: entity.x, y: entity.y }]),
),
);
const [hoveredEntity, setHoveredEntity] = useState<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const dragStartRef = useRef<{
entityId: string;
posX: number;
posY: number;
startX: number;
startY: number;
} | null>(null);
const handlePointerDown = (entityId: string, event: React.PointerEvent) => {
event.preventDefault();
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
const pos = positions[entityId];
dragStartRef.current = {
entityId,
startX: event.clientX,
startY: event.clientY,
posX: pos.x,
posY: pos.y,
};
setDragging(entityId);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!dragStartRef.current) return;
const { entityId, startX, startY, posX, posY } = dragStartRef.current;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
setPositions((prev) => ({
...prev,
[entityId]: { x: posX + dx, y: posY + dy },
}));
};
const handlePointerUp = () => {
dragStartRef.current = null;
setDragging(null);
};
const isConnectionHighlighted = (connection: ConnectionDef) =>
hoveredEntity === connection.from || hoveredEntity === connection.to;
return (
<AppPreviewShell active={active} title="Data model">
<ShellCanvas
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<ShellSvgLayer>
{CONNECTIONS.map((conn) => (
<DrawEdge
key={`${conn.from}-${conn.to}`}
circleR={1.5}
elbow="horizontal-first"
from={getCardCenter(positions, conn.from)}
highlighted={isConnectionHighlighted(conn)}
to={getCardCenter(positions, conn.to)}
/>
))}
</ShellSvgLayer>
{ENTITIES.map((entity) => {
const pos = positions[entity.id];
const isHovered = hoveredEntity === entity.id;
return (
<EntityCard
key={entity.id}
$hovered={isHovered || dragging === entity.id}
onPointerDown={(event) => handlePointerDown(entity.id, event)}
onPointerEnter={() => setHoveredEntity(entity.id)}
onPointerLeave={() => setHoveredEntity(null)}
style={{
left: pos.x,
top: pos.y,
transition:
dragging === entity.id ? 'none' : 'border-color 0.15s',
}}
>
<EntityHeader>
<EntityIcon $bg={entity.iconBg} $border={entity.iconBorder}>
{entity.headerIcon}
</EntityIcon>
<EntityLabel>{entity.label}</EntityLabel>
<EntityMeta>· {entity.meta}</EntityMeta>
<MetaBadge>
<MetaBadgeIcon
$bg={entity.isCustom ? BADGE_CUSTOM_BG : BADGE_STANDARD_BG}
$border={
entity.isCustom
? BADGE_CUSTOM_BORDER
: BADGE_STANDARD_BORDER
}
$color={
entity.isCustom ? BADGE_CUSTOM_TEXT : BADGE_STANDARD_TEXT
}
>
L
</MetaBadgeIcon>
<MetaBadgeText>
{entity.isCustom ? 'Custom' : 'Standard'}
</MetaBadgeText>
</MetaBadge>
</EntityHeader>
<InnerCard>
{entity.fields.map((field) => (
<FieldRow key={field.label}>
<FieldIcon>{field.icon}</FieldIcon>
{field.label}
</FieldRow>
))}
<ExpandHint>
<IconChevronDown /> {entity.expandCount} fields
</ExpandHint>
</InnerCard>
</EntityCard>
);
})}
</ShellCanvas>
</AppPreviewShell>
);
}

View File

@@ -0,0 +1,62 @@
import {
STEPPER_BORDER_MEDIUM,
STEPPER_BORDER_STRONG,
} from './stepper-visual-tokens';
type Point = { x: number; y: number };
type DrawEdgeProps = {
circleR?: number;
elbow?: 'horizontal-first' | 'vertical-first';
from: Point;
highlighted: boolean;
to: Point;
};
export function DrawEdge({
circleR = 2,
elbow = 'vertical-first',
from,
highlighted,
to,
}: DrawEdgeProps) {
const color = highlighted ? STEPPER_BORDER_STRONG : STEPPER_BORDER_MEDIUM;
const dx = Math.abs(to.x - from.x);
const dy = Math.abs(to.y - from.y);
let pathD: string;
let startX = from.x;
let startY = from.y;
let endX = to.x;
let endY = to.y;
if (dx < 30) {
const avgX = (from.x + to.x) / 2;
startX = avgX;
endX = avgX;
pathD = `M${avgX},${from.y} L${avgX},${to.y}`;
} else if (dy < 30) {
const avgY = (from.y + to.y) / 2;
startY = avgY;
endY = avgY;
pathD = `M${from.x},${avgY} L${to.x},${avgY}`;
} else if (elbow === 'horizontal-first') {
pathD = `M${from.x},${from.y} L${to.x},${from.y} L${to.x},${to.y}`;
} else {
pathD = `M${from.x},${from.y} L${from.x},${to.y} L${to.x},${to.y}`;
}
return (
<g>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={0.75}
style={{ transition: 'stroke 0.15s' }}
/>
<circle cx={startX} cy={startY} fill={color} r={circleR} />
<circle cx={endX} cy={endY} fill={color} r={circleR} />
</g>
);
}

View File

@@ -0,0 +1,312 @@
'use client';
import { useRef, useState } from 'react';
import type { StepperVisualProps } from '../types';
import { FIELDS, NAV } from './data/layout.data';
import {
CheckSvg,
ChevLeft,
DotsV,
DotsVW,
EyeIcon,
FieldIcon,
GripV,
ListSvg,
NavSvgIcon,
NewSecSvg,
PaintSvg,
PlusSvg,
SparkSvg,
} from './icons/LayoutIcons';
import {
ActionBtn,
ActionsBar,
BlueHeader,
Canvas,
HeaderCenter,
HeaderLeft,
HeaderSave,
HeaderTitle,
MainCard,
NavBreadcrumb,
NavChevron,
NavIconBox,
NavItem,
NavPanel,
NavSectionLabel,
NavSubItem,
NavSuffix,
RightPanel,
RPActionBtn,
RPAddIconBox,
RPAddSection,
RPAddText,
RPBackBtn,
RPDoneBtn,
RPEditable,
RPEditText,
RPFieldDot,
RPFieldIconBox,
RPFieldLabels,
RPFieldName,
RPFieldRow,
RPFieldType,
RPFields,
RPHeader,
RPIconBox,
RPNewDesc,
RPNewFields,
RPNewTitle,
RPSectionName,
RPSectionRow,
RPSubBar,
RPSubLabel,
RPTitleBold,
RPTitleGroup,
RPTitleSub,
WChip,
WIcon,
WLabel,
WRow,
WSectionLabel,
WValue,
WidgetInner,
WidgetPanel,
WidgetTitle,
} from './layout-styles';
export function LayoutVisual({ active }: StepperVisualProps) {
const [fields, setFields] = useState(FIELDS);
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartY = useRef(0);
const toggleVisibility = (fieldId: string) => {
setFields((prev) =>
prev.map((f) => (f.id === fieldId ? { ...f, visible: !f.visible } : f)),
);
};
const handleDragStart = (fieldId: string, event: React.PointerEvent) => {
event.preventDefault();
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
setDraggingId(fieldId);
dragStartY.current = event.clientY;
};
const handleDragMove = (event: React.PointerEvent) => {
if (!draggingId) return;
const dy = event.clientY - dragStartY.current;
const steps = Math.round(dy / 22);
if (steps === 0) return;
setFields((prev) => {
const idx = prev.findIndex((f) => f.id === draggingId);
if (idx === -1) return prev;
const to = Math.max(0, Math.min(prev.length - 1, idx + steps));
if (to === idx) return prev;
const next = [...prev];
const [moved] = next.splice(idx, 1);
next.splice(to, 0, moved);
return next;
});
dragStartY.current = event.clientY;
};
const handleDragEnd = () => setDraggingId(null);
const sections = [...new Set(fields.map((f) => f.section))];
return (
<Canvas style={{ opacity: active ? 1 : 0.6, transition: 'opacity 0.3s' }}>
<MainCard>
<BlueHeader>
<HeaderLeft>
<DotsVW />
</HeaderLeft>
<HeaderCenter>
<PaintSvg />
<HeaderTitle>Layout edition</HeaderTitle>
</HeaderCenter>
<HeaderSave>
<CheckSvg /> Save
</HeaderSave>
</BlueHeader>
</MainCard>
<WidgetPanel>
<WidgetInner>
<WidgetTitle>Widget name</WidgetTitle>
<WSectionLabel>General</WSectionLabel>
<WRow>
<WIcon>
<FieldIcon type="link" />
</WIcon>
<WLabel>URL</WLabel>
<WChip>qonto.com</WChip>
</WRow>
<WRow>
<WIcon>
<FieldIcon type="user" />
</WIcon>
<WLabel>Account O...</WLabel>
<WValue>Phil Schiller</WValue>
</WRow>
<WRow>
<WIcon>
<FieldIcon type="map" />
</WIcon>
<WLabel>Address</WLabel>
<WValue>18 Rue De Navarin, 750...</WValue>
</WRow>
<WRow>
<WIcon>
<FieldIcon type="target" />
</WIcon>
<WLabel>ICP</WLabel>
<WValue> True</WValue>
</WRow>
</WidgetInner>
</WidgetPanel>
<NavPanel>
<NavSectionLabel>Workspace</NavSectionLabel>
{NAV.map((item) => (
<div key={item.label}>
<NavItem $active={item.active}>
<NavIconBox $bg={item.bg}>
<NavSvgIcon type={item.icon} />
</NavIconBox>
{item.label}
{'suffix' in item && item.suffix && (
<NavSuffix>· {item.suffix}</NavSuffix>
)}
{'folder' in item && item.folder && <NavChevron></NavChevron>}
</NavItem>
{'children' in item &&
item.children?.map((child) => (
<NavSubItem key={child.label}>
<NavBreadcrumb />
<NavIconBox $bg={child.bg}>
<NavSvgIcon type={child.icon} />
</NavIconBox>
{child.label}
</NavSubItem>
))}
</div>
))}
</NavPanel>
<ActionsBar>
<ActionBtn>+ New record</ActionBtn>
<ActionBtn> Enrich</ActionBtn>
<ActionBtn> Edit actions</ActionBtn>
</ActionsBar>
<RightPanel
onPointerCancel={handleDragEnd}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
<RPHeader>
<RPBackBtn>
<ChevLeft />
</RPBackBtn>
<RPIconBox>
<ListSvg />
</RPIconBox>
<RPTitleGroup>
<RPTitleBold>Fields</RPTitleBold>
<RPTitleSub>Fields widget</RPTitleSub>
</RPTitleGroup>
<RPActionBtn>
<SparkSvg />
</RPActionBtn>
</RPHeader>
<RPSubBar>
<RPBackBtn>
<ChevLeft />
</RPBackBtn>
<RPSubLabel>Layout</RPSubLabel>
</RPSubBar>
<RPFields>
{sections.map((section, si) => (
<div key={section}>
<RPSectionRow>
<GripV />
<RPSectionName>{section}</RPSectionName>
<RPActionBtn>
<DotsV />
</RPActionBtn>
</RPSectionRow>
{si === 0 && (
<RPEditable>
<RPEditText>Industry</RPEditText>
<RPDoneBtn>Done</RPDoneBtn>
</RPEditable>
)}
{fields
.filter((f) => f.section === section)
.map((field) => (
<RPFieldRow
key={field.id}
$dragging={draggingId === field.id}
onPointerDown={(e) => handleDragStart(field.id, e)}
>
<RPFieldIconBox>
<FieldIcon type={field.icon} />
</RPFieldIconBox>
<RPFieldLabels>
<RPFieldName>{field.label}</RPFieldName>
<RPFieldDot>·</RPFieldDot>
<RPFieldType>{field.type}</RPFieldType>
</RPFieldLabels>
<RPActionBtn
onClick={(e) => {
e.stopPropagation();
toggleVisibility(field.id);
}}
>
<EyeIcon visible={field.visible} />
</RPActionBtn>
<RPActionBtn>
<DotsV />
</RPActionBtn>
</RPFieldRow>
))}
{si > 0 && si < sections.length - 1 && (
<RPAddSection>
<RPAddIconBox>
<NewSecSvg />
</RPAddIconBox>
<RPAddText>Add a Section</RPAddText>
</RPAddSection>
)}
</div>
))}
<RPNewFields>
<RPAddIconBox>
<PlusSvg />
</RPAddIconBox>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<RPNewTitle>New fields</RPNewTitle>
<RPNewDesc>Default position/visibility for field</RPNewDesc>
</div>
</RPNewFields>
<RPAddSection>
<RPAddIconBox>
<NewSecSvg />
</RPAddIconBox>
<RPAddText>Add a Section</RPAddText>
</RPAddSection>
</RPFields>
</RightPanel>
</Canvas>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import type { StepperVisualProps } from '../types';
import { AppPreviewShell, ShellCanvas, ShellSvgLayer } from './AppPreviewShell';
import {
COLOR_GRAY,
COLOR_GRAY_BG,
COLOR_GREEN,
COLOR_TEAL_BG,
EDGES,
NODE_HEIGHT,
NODE_WIDTH,
NODES,
} from './data/workflow.data';
import { DrawEdge } from './DrawEdge';
import { CheckIcon, NodeIcon } from './icons/WorkflowIcons';
import {
STEPPER_BORDER_STRONG,
STEPPER_CARD_BG,
STEPPER_TEXT,
STEPPER_TEXT_MUTED,
STEPPER_TEXT_TERTIARY,
STEPPER_TINT,
} from './stepper-visual-tokens';
import { useWorkflowAnimation } from './use-workflow-animation';
const NodeCard = styled.div`
align-items: center;
background: ${STEPPER_CARD_BG};
border: 1px solid ${STEPPER_BORDER_STRONG};
border-radius: 8px;
box-shadow:
0 0 2px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
display: flex;
gap: 8px;
min-width: ${NODE_WIDTH}px;
padding: 8px;
position: absolute;
z-index: 2;
`;
const NodeIconBox = styled.div`
align-items: center;
background: ${STEPPER_TINT};
border-radius: 4px;
color: ${STEPPER_TEXT_MUTED};
display: flex;
flex-shrink: 0;
height: 30px;
justify-content: center;
width: 30px;
`;
const NodeRight = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const NodeLabelRow = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 13px;
`;
const NodeLabel = styled.span<{ $color: string }>`
color: ${({ $color }) => $color};
font-size: 10px;
font-weight: 600;
`;
const NodeCheck = styled.span<{ $bg: string; $visible: boolean }>`
align-items: center;
background: ${({ $bg }) => $bg};
border-radius: 2px;
display: flex;
height: 12px;
justify-content: center;
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
transition: opacity 0.3s;
width: 12px;
`;
const NodeName = styled.div<{ $dimmed?: boolean }>`
color: ${({ $dimmed }) => ($dimmed ? STEPPER_TEXT_TERTIARY : STEPPER_TEXT)};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const IterationLabel = styled.span`
background: ${STEPPER_CARD_BG};
border: 1px solid ${STEPPER_BORDER_STRONG};
border-radius: 4px;
color: ${STEPPER_TEXT_TERTIARY};
font-size: 9px;
font-weight: 600;
padding: 2px 4px;
position: absolute;
white-space: nowrap;
z-index: 3;
`;
function getNodeCenter(pos: { x: number; y: number }): {
x: number;
y: number;
} {
return { x: pos.x + NODE_WIDTH / 2, y: pos.y + NODE_HEIGHT / 2 };
}
export function WorkflowVisual({ active }: StepperVisualProps) {
const [positions, setPositions] = useState<
Record<string, { x: number; y: number }>
>(() =>
Object.fromEntries(
NODES.map((node) => [node.id, { x: node.x, y: node.y }]),
),
);
const [dragging, setDragging] = useState<string | null>(null);
const dragStartRef = useRef<{
nodeId: string;
posX: number;
posY: number;
startX: number;
startY: number;
} | null>(null);
const activeNodes = useWorkflowAnimation(active);
const handlePointerDown = (nodeId: string, event: React.PointerEvent) => {
event.preventDefault();
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
const pos = positions[nodeId];
dragStartRef.current = {
nodeId,
startX: event.clientX,
startY: event.clientY,
posX: pos.x,
posY: pos.y,
};
setDragging(nodeId);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!dragStartRef.current) return;
const { nodeId, startX, startY, posX, posY } = dragStartRef.current;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
setPositions((prev) => ({
...prev,
[nodeId]: { x: posX + dx, y: posY + dy },
}));
};
const handlePointerUp = () => {
dragStartRef.current = null;
setDragging(null);
};
return (
<AppPreviewShell active={active} title="Workflow Runs">
<ShellCanvas
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<ShellSvgLayer>
{EDGES.map((edge) => {
const fromPos = positions[edge.from];
const toPos = positions[edge.to];
if (!fromPos || !toPos) return null;
return (
<DrawEdge
key={`${edge.from}-${edge.to}`}
circleR={2.5}
from={getNodeCenter(fromPos)}
highlighted={
activeNodes.has(edge.from) && activeNodes.has(edge.to)
}
to={getNodeCenter(toPos)}
/>
);
})}
{(() => {
const iterPos = positions['iterator'];
const emailPos = positions['email'];
if (!iterPos || !emailPos) return null;
const startX = iterPos.x + NODE_WIDTH;
const startY = iterPos.y + NODE_HEIGHT / 2;
const horizEnd = startX + 117;
const r = 8;
const vertEnd = emailPos.y;
const emailCenterX = emailPos.x + NODE_WIDTH / 2;
const midY = vertEnd - 28;
const loopColor = STEPPER_BORDER_STRONG;
const ah = 3;
return (
<g>
<circle cx={startX} cy={startY} fill={loopColor} r={2.5} />
<path
d={[
`M${startX},${startY}`,
`H${horizEnd - r}`,
`Q${horizEnd},${startY} ${horizEnd},${startY + r}`,
`V${midY - r}`,
`Q${horizEnd},${midY} ${horizEnd - r},${midY}`,
`H${emailCenterX + r}`,
`Q${emailCenterX},${midY} ${emailCenterX},${midY + r}`,
`V${vertEnd}`,
].join(' ')}
fill="none"
stroke={loopColor}
strokeWidth={0.75}
/>
<path
d={`M${emailCenterX - ah},${vertEnd - ah} L${emailCenterX},${vertEnd} L${emailCenterX + ah},${vertEnd - ah}`}
fill="none"
stroke={loopColor}
strokeWidth={0.75}
/>
<circle
cx={emailCenterX}
cy={vertEnd}
fill={loopColor}
r={2.5}
/>
</g>
);
})()}
</ShellSvgLayer>
{NODES.map((node) => {
const pos = positions[node.id];
const isActive = activeNodes.has(node.id);
const checkBg = node.dimmed ? COLOR_GRAY_BG : COLOR_TEAL_BG;
const checkColor = node.dimmed ? COLOR_GRAY : COLOR_GREEN;
return (
<NodeCard
key={node.id}
onPointerDown={(event) => handlePointerDown(node.id, event)}
style={{
cursor: dragging === node.id ? 'grabbing' : 'grab',
left: pos.x,
top: pos.y,
transition: dragging === node.id ? 'none' : 'box-shadow 0.15s',
}}
>
<NodeIconBox>
<NodeIcon name={node.icon} />
</NodeIconBox>
<NodeRight>
<NodeLabelRow>
<NodeLabel $color={node.labelColor}>{node.type}</NodeLabel>
{node.badge && (
<NodeLabel $color={node.labelColor}>{node.badge}</NodeLabel>
)}
{node.badge && (
<NodeCheck $bg={checkBg} $visible={isActive}>
<CheckIcon color={checkColor} />
</NodeCheck>
)}
</NodeLabelRow>
<NodeName $dimmed={node.dimmed}>{node.label}</NodeName>
</NodeRight>
</NodeCard>
);
})}
<IterationLabel
style={{
left: positions['iterator'].x + NODE_WIDTH + 10,
top: positions['iterator'].y + NODE_HEIGHT / 2 - 9,
}}
>
iteration 2/3
</IterationLabel>
</ShellCanvas>
</AppPreviewShell>
);
}

View File

@@ -0,0 +1,142 @@
import type { ReactNode } from 'react';
import {
IconApps,
IconBuilding,
IconBuildingSm,
IconTag,
IconTarget,
IconUser,
IconUserScreenSm,
IconUserSm,
IconUsersSm,
IconTargetSm,
} from '../icons/DataModelIcons';
const COLOR_INDIGO_BG = '#d9e2fc';
const COLOR_INDIGO_BORDER = '#c6d4f9';
const COLOR_PURPLE_BG = '#eddbf9';
const COLOR_PURPLE_BORDER = '#e3ccf4';
const COLOR_RED_BG = '#fdd8d8';
const COLOR_RED_BORDER = '#f9c6c6';
const COLOR_GREEN_BG = '#d4f4e2';
const COLOR_GREEN_BORDER = '#b4e7cf';
export const BADGE_STANDARD_BG = '#f0f4ff';
export const BADGE_STANDARD_BORDER = '#e6edfe';
export const BADGE_STANDARD_TEXT = '#3e63dd';
export const BADGE_CUSTOM_BG = '#fff1e7';
export const BADGE_CUSTOM_BORDER = '#ffe8d7';
export const BADGE_CUSTOM_TEXT = '#f76808';
export type EntityDef = {
expandCount: number;
fields: { icon: ReactNode; label: string }[];
headerIcon: ReactNode;
iconBg: string;
iconBorder: string;
id: string;
isCustom: boolean;
label: string;
meta: string;
x: number;
y: number;
};
export type ConnectionDef = {
from: string;
to: string;
};
export const ENTITIES: EntityDef[] = [
{
id: 'workspaces',
label: 'Workspaces',
meta: '22',
isCustom: true,
headerIcon: <IconUserScreenSm />,
iconBg: COLOR_GREEN_BG,
iconBorder: COLOR_GREEN_BORDER,
fields: [
{ icon: <IconBuilding />, label: 'Company' },
{ icon: <IconUser />, label: 'Users' },
],
expandCount: 21,
x: 40,
y: 40,
},
{
id: 'companies',
label: 'Companies',
meta: '39',
isCustom: false,
headerIcon: <IconBuildingSm />,
iconBg: COLOR_INDIGO_BG,
iconBorder: COLOR_INDIGO_BORDER,
fields: [
{ icon: <IconApps />, label: 'Workspace' },
{ icon: <IconTag />, label: '31 fields' },
],
expandCount: 8,
x: 290,
y: 20,
},
{
id: 'users',
label: 'Users',
meta: '497',
isCustom: true,
headerIcon: <IconUsersSm />,
iconBg: COLOR_PURPLE_BG,
iconBorder: COLOR_PURPLE_BORDER,
fields: [
{ icon: <IconUser />, label: 'People' },
{ icon: <IconApps />, label: 'Workspace' },
],
expandCount: 32,
x: 40,
y: 310,
},
{
id: 'people',
label: 'People',
meta: '648',
isCustom: false,
headerIcon: <IconUserSm />,
iconBg: COLOR_INDIGO_BG,
iconBorder: COLOR_INDIGO_BORDER,
fields: [
{ icon: <IconBuilding />, label: 'Company' },
{ icon: <IconUser />, label: 'Users' },
{ icon: <IconTarget />, label: 'Opportunity' },
],
expandCount: 4,
x: 280,
y: 400,
},
{
id: 'opportunities',
label: 'Opportunities',
meta: '42',
isCustom: false,
headerIcon: <IconTargetSm />,
iconBg: COLOR_RED_BG,
iconBorder: COLOR_RED_BORDER,
fields: [
{ icon: <IconBuilding />, label: 'Company' },
{ icon: <IconTag />, label: '12 fields' },
],
expandCount: 23,
x: 380,
y: 190,
},
];
export const CONNECTIONS: ConnectionDef[] = [
{ from: 'workspaces', to: 'companies' },
{ from: 'workspaces', to: 'users' },
{ from: 'users', to: 'people' },
{ from: 'companies', to: 'people' },
{ from: 'companies', to: 'opportunities' },
{ from: 'people', to: 'opportunities' },
];

View File

@@ -0,0 +1,104 @@
import type { LayoutFieldIconType } from '../icons/LayoutIcons';
export type FieldDef = {
icon: LayoutFieldIconType;
id: string;
label: string;
section: string;
type: string;
visible: boolean;
};
export const FIELDS: FieldDef[] = [
{
id: 'url',
icon: 'link',
label: 'URL',
type: 'Link',
section: 'General',
visible: true,
},
{
id: 'account-owner',
icon: 'user',
label: 'Account Owner',
type: 'Relation',
section: 'General',
visible: true,
},
{
id: 'revenue',
icon: 'money',
label: 'Revenue',
type: 'Currency',
section: 'General',
visible: true,
},
{
id: 'icp',
icon: 'target',
label: 'ICP',
type: 'Boolean',
section: 'Additional',
visible: false,
},
{
id: 'employees',
icon: 'users',
label: 'Employees',
type: 'Number',
section: 'Other',
visible: true,
},
{
id: 'address',
icon: 'map',
label: 'Address',
type: 'Address',
section: 'Other',
visible: true,
},
{
id: 'creation-date',
icon: 'calendar',
label: 'Creation date',
type: 'Date & Time',
section: 'Other',
visible: true,
},
];
export const NAV = [
{ icon: 'building', label: 'Companies', active: true, bg: '#d9e2fc' },
{ icon: 'user', label: 'People', active: false, bg: '#d9e2fc' },
{ icon: 'target', label: 'Opportunities', active: false, bg: '#fdd8d8' },
{ icon: 'checkbox', label: 'Tasks', active: false, bg: '#c7ebe5' },
{ icon: 'notes', label: 'Notes', active: false, bg: '#c7ebe5' },
{
icon: 'letter-S',
label: 'Sales Dashboard',
active: false,
bg: '#fef2a4',
suffix: 'Dashboard',
},
{
icon: 'automation',
label: 'Workflows',
active: false,
bg: '#ffdcc3',
folder: true,
children: [
{ icon: 'automation', label: 'Workflows', bg: '#ebebeb' },
{ icon: 'play', label: 'Workflows runs', bg: '#ebebeb' },
{ icon: 'versions', label: 'Workflows versions', bg: '#ebebeb' },
],
},
{ icon: 'ai', label: 'Claude', active: false, bg: '#ebebeb' },
{
icon: 'stripe-S',
label: 'Stripe',
active: false,
bg: '#ebebeb',
folder: true,
},
] as const;

View File

@@ -0,0 +1,111 @@
import type { WorkflowIconName } from '../icons/WorkflowIcons';
export const COLOR_GREEN = '#30a46c';
const COLOR_AMBER = '#946800';
export const COLOR_GRAY = '#999';
export const COLOR_TEAL_BG = '#e7f9f5';
export const COLOR_GRAY_BG = '#f9f9f9';
export type NodeDef = {
badge?: string;
dimmed?: boolean;
icon: WorkflowIconName;
id: string;
label: string;
labelColor: string;
type: string;
x: number;
y: number;
};
export type EdgeDef = {
from: string;
to: string;
};
const TRUNK_X = 55;
const RIGHT_X = 200;
const LEFT_X = 5;
export const NODES: NodeDef[] = [
{
id: 'trigger',
type: 'Trigger',
label: 'Record is Created',
icon: 'playlist-add',
labelColor: COLOR_GREEN,
x: TRUNK_X,
y: 16,
badge: '1',
},
{
id: 'search',
type: 'Action',
label: 'Search Records',
icon: 'search',
labelColor: COLOR_GREEN,
x: TRUNK_X,
y: 92,
badge: '1',
},
{
id: 'iterator',
type: 'Flow',
label: 'Iterator',
icon: 'repeat',
labelColor: COLOR_AMBER,
x: TRUNK_X,
y: 168,
},
{
id: 'email',
type: 'Action',
label: 'Send Email',
icon: 'send',
labelColor: COLOR_AMBER,
x: RIGHT_X,
y: 280,
},
{
id: 'update',
type: 'Action',
label: 'Update Record',
icon: 'reload',
labelColor: COLOR_GRAY,
x: LEFT_X,
y: 340,
badge: '3',
dimmed: true,
},
{
id: 'create',
type: 'Action',
label: 'Create Record',
icon: 'plus',
labelColor: COLOR_GREEN,
x: RIGHT_X - 5,
y: 400,
badge: '1',
},
];
export const EDGES: EdgeDef[] = [
{ from: 'trigger', to: 'search' },
{ from: 'search', to: 'iterator' },
{ from: 'iterator', to: 'update' },
{ from: 'email', to: 'create' },
];
export const ANIMATION_SEQUENCE = [
'trigger',
'search',
'iterator',
'email',
'update',
'create',
];
export const STEP_INTERVAL_MS = 800;
export const NODE_WIDTH = 170;
export const NODE_HEIGHT = 48;

View File

@@ -0,0 +1,196 @@
export const IconBuilding = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M3 21l18 0" />
<path d="M5 21v-14l8 -4v18" />
<path d="M19 21v-10l-6 -4" />
<path d="M9 9l0 .01" />
<path d="M9 12l0 .01" />
<path d="M9 15l0 .01" />
<path d="M9 18l0 .01" />
</svg>
);
export const IconUser = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
</svg>
);
export const IconApps = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
<path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
<path d="M14 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
</svg>
);
export const IconTag = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M7.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3z" />
</svg>
);
export const IconTarget = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 7a5 5 0 1 0 5 5" />
<path d="M13 3.055a9 9 0 1 0 7.941 7.945" />
<path d="M15 6v3h3l3 -3h-3v-3z" />
<path d="M15 9l-3 3" />
</svg>
);
export const IconChevronDown = () => (
<svg
fill="none"
height={12}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={12}
>
<path d="M6 9l6 -6" />
<path d="M6 9l-6 -6" />
</svg>
);
export const IconBuildingSm = () => (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
width={9}
>
<path d="M3 21l18 0" />
<path d="M5 21v-14l8 -4v18" />
<path d="M19 21v-10l-6 -4" />
<path d="M9 9l0 .01" />
<path d="M9 12l0 .01" />
<path d="M9 15l0 .01" />
<path d="M9 18l0 .01" />
</svg>
);
export const IconUsersSm = () => (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
viewBox="0 0 24 24"
width={9}
>
<path d="M9 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85" />
</svg>
);
export const IconTargetSm = () => (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
width={9}
>
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 7a5 5 0 1 0 5 5" />
<path d="M13 3.055a9 9 0 1 0 7.941 7.945" />
<path d="M15 6v3h3l3 -3h-3v-3z" />
<path d="M15 9l-3 3" />
</svg>
);
export const IconUserScreenSm = () => (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
viewBox="0 0 24 24"
width={9}
>
<path d="M19.03 17.818a3 3 0 0 0 1.97 -2.818v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8c0 1.317 .85 2.436 2.03 2.84" />
<path d="M10 14a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M8 21a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2" />
</svg>
);
export const IconUserSm = () => (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
width={9}
>
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" />
</svg>
);

View File

@@ -0,0 +1,450 @@
export type LayoutFieldIconType =
| 'link'
| 'user'
| 'money'
| 'target'
| 'users'
| 'map'
| 'calendar';
export function FieldIcon({ type }: { type: LayoutFieldIconType }) {
const c = '#666';
const z = 10;
switch (type) {
case 'link':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M7 9a3.5 3.5 0 005 0l2-2a3.536 3.536 0 00-5-5L8 3m1 4a3.5 3.5 0 00-5 0L2 9a3.536 3.536 0 005 5l1-1"
stroke={c}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'user':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="8" cy="5.5" r="2.5" stroke={c} strokeWidth={1.2} />
<path
d="M3.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
stroke={c}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
case 'money':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M8 2v12M5 4.5h4.5a1.5 1.5 0 010 3H5.5a1.5 1.5 0 000 3H11"
stroke={c}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'target':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="8" cy="8" r="6" stroke={c} strokeWidth={1.2} />
<circle cx="8" cy="8" r="3" stroke={c} strokeWidth={1.2} />
<circle cx="8" cy="8" fill={c} r="1" />
</svg>
);
case 'users':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="6" cy="5" r="2" stroke={c} strokeWidth={1.2} />
<path
d="M2 13c0-2 1.5-3.5 4-3.5s4 1.5 4 3.5"
stroke={c}
strokeLinecap="round"
strokeWidth={1.2}
/>
<circle cx="11" cy="5.5" r="1.5" stroke={c} strokeWidth={1.2} />
<path
d="M12 9.5c1.5.5 2.5 1.5 2.5 3"
stroke={c}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
case 'map':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M3 4l3.5-1.5 3 1.5L13 2.5v9.5l-3.5 1.5-3-1.5L3 13.5V4z"
stroke={c}
strokeLinejoin="round"
strokeWidth={1.2}
/>
<path d="M6.5 2.5v9M9.5 4v9" stroke={c} strokeWidth={1.2} />
</svg>
);
case 'calendar':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<rect
height="10"
rx="1.5"
stroke={c}
strokeWidth={1.2}
width="10"
x="3"
y="3.5"
/>
<path d="M3 7h10M6 2v2M10 2v2" stroke={c} strokeWidth={1.2} />
</svg>
);
default:
return null;
}
}
export function NavSvgIcon({ type }: { type: string }) {
const c = '#555';
const z = 7;
switch (type) {
case 'building':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M5 14V3l6 2v9M5 6H3v8h12V7h-4"
stroke={c}
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'user':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="8" cy="5" r="2.5" stroke={c} strokeWidth={1.4} />
<path
d="M3.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
stroke={c}
strokeLinecap="round"
strokeWidth={1.4}
/>
</svg>
);
case 'target':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="8" cy="8" r="5" stroke={c} strokeWidth={1.4} />
<circle cx="8" cy="8" r="2" stroke={c} strokeWidth={1.4} />
<path d="M8 3v2M13 8h-2" stroke={c} strokeWidth={1.4} />
</svg>
);
case 'checkbox':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<rect
height="10"
rx="2"
stroke={c}
strokeWidth={1.4}
width="10"
x="3"
y="3"
/>
<path
d="M6 8l1.5 1.5L10 6"
stroke={c}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'notes':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<rect
height="10"
rx="1.5"
stroke={c}
strokeWidth={1.4}
width="8"
x="4"
y="3"
/>
<path
d="M6.5 6h3M6.5 8.5h3"
stroke={c}
strokeLinecap="round"
strokeWidth={1.4}
/>
</svg>
);
case 'automation':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<circle cx="8" cy="8" r="5" stroke={c} strokeWidth={1.4} />
<path
d="M8 5v3l2 1"
stroke={c}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'play':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M5 3.5l8 4.5-8 4.5V3.5z"
stroke={c}
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'versions':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<rect
height="8"
rx="1"
stroke={c}
strokeWidth={1.4}
width="6"
x="5"
y="4"
/>
<path d="M3 6v6a1 1 0 001 1h6" stroke={c} strokeWidth={1.4} />
</svg>
);
case 'ai':
return (
<svg fill="none" height={z} viewBox="0 0 16 16" width={z}>
<path
d="M8 2l1.5 4L14 8l-4.5 2L8 14l-1.5-4L2 8l4.5-2L8 2z"
stroke={c}
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'letter-S':
return (
<svg height={z} viewBox="0 0 16 16" width={z}>
<text
fill="#35290f"
fontFamily="Inter"
fontSize="9"
fontWeight="600"
textAnchor="middle"
x="8"
y="12"
>
S
</text>
</svg>
);
case 'stripe-S':
return (
<svg height={z} viewBox="0 0 16 16" width={z}>
<text
fill="#333"
fontFamily="Inter"
fontSize="9"
fontWeight="600"
textAnchor="middle"
x="8"
y="12"
>
S
</text>
</svg>
);
default:
return null;
}
}
export function EyeIcon({ visible }: { visible: boolean }) {
if (visible)
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M2 8s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z"
stroke="#999"
strokeLinejoin="round"
strokeWidth={1.2}
/>
<circle cx="8" cy="8" r="1.5" stroke="#999" strokeWidth={1.2} />
</svg>
);
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M2 8s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z"
stroke="#b3b3b3"
strokeLinejoin="round"
strokeWidth={1.2}
/>
<path
d="M3 3l10 10"
stroke="#b3b3b3"
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function ChevLeft() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M10 4l-4 4 4 4"
stroke="#999"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function ChevDown() {
return (
<svg fill="none" height={7} viewBox="0 0 16 16" width={7}>
<path
d="M4 6l4 4 4-4"
stroke="#999"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.3}
/>
</svg>
);
}
export function DotsV() {
return (
<svg fill="none" height={10} viewBox="0 0 4 12" width={5}>
<circle cx="2" cy="2" fill="#999" r="0.8" />
<circle cx="2" cy="6" fill="#999" r="0.8" />
<circle cx="2" cy="10" fill="#999" r="0.8" />
</svg>
);
}
export function DotsVW() {
return (
<svg fill="none" height={12} viewBox="0 0 4 12" width={5}>
<circle cx="2" cy="2" fill="white" r="1" />
<circle cx="2" cy="6" fill="white" r="1" />
<circle cx="2" cy="10" fill="white" r="1" />
</svg>
);
}
export function GripV() {
return (
<svg fill="none" height={10} viewBox="0 0 8 12" width={7}>
<circle cx="3" cy="2" fill="#999" r="0.8" />
<circle cx="5" cy="2" fill="#999" r="0.8" />
<circle cx="3" cy="6" fill="#999" r="0.8" />
<circle cx="5" cy="6" fill="#999" r="0.8" />
<circle cx="3" cy="10" fill="#999" r="0.8" />
<circle cx="5" cy="10" fill="#999" r="0.8" />
</svg>
);
}
export function PaintSvg() {
return (
<svg fill="none" height={12} viewBox="0 0 16 16" width={12}>
<rect
height="6"
rx="1"
stroke="white"
strokeWidth={1.2}
width="8"
x="4"
y="3"
/>
<path
d="M6 9v3a1 1 0 001 1h0a1 1 0 001-1V9"
stroke="white"
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function CheckSvg() {
return (
<svg fill="none" height={8} viewBox="0 0 16 16" width={8}>
<path
d="M3 8l4 4 6-8"
stroke="white"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
);
}
export function ListSvg() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M3 4h4M3 8h4M3 12h4M9 4h4M9 8h4M9 12h4"
stroke="#666"
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function SparkSvg() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M8 2l1.5 4.5L14 8l-4.5 1.5L8 14l-1.5-4.5L2 8l4.5-1.5L8 2z"
stroke="#999"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function NewSecSvg() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M3 4h10M3 8h5M3 12h10M12 8v4M10 10h4"
stroke="#666"
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
export function PlusSvg() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M8 3v10M3 8h10"
stroke="#666"
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}

View File

@@ -0,0 +1,130 @@
export type WorkflowIconName =
| 'playlist-add'
| 'search'
| 'repeat'
| 'send'
| 'reload'
| 'plus';
const COLOR_AMBER = '#946800';
export function NodeIcon({ name }: { name: WorkflowIconName }) {
switch (name) {
case 'playlist-add':
return (
<svg
fill="none"
height={16}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M19 8h-14" />
<path d="M5 12h9" />
<path d="M11 16h-6" />
<path d="M15 16h6" />
<path d="M18 13v6" />
</svg>
);
case 'search':
return (
<svg
fill="none"
height={16}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
<path d="M21 21l-6 -6" />
</svg>
);
case 'repeat':
return (
<svg
fill="none"
height={16}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
</svg>
);
case 'send':
return (
<svg
fill="none"
height={16}
stroke={COLOR_AMBER}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M10 14l11 -11" />
<path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5" />
</svg>
);
case 'reload':
return (
<svg
fill="none"
height={16}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1.002 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</svg>
);
case 'plus':
return (
<svg
fill="none"
height={16}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
width={16}
>
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
}
}
export function CheckIcon({ color }: { color: string }) {
return (
<svg
fill="none"
height="8"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
width="8"
>
<path d="M5 12l5 5l10 -10" />
</svg>
);
}

View File

@@ -0,0 +1,486 @@
import { styled } from '@linaria/react';
import {
STEPPER_BORDER_LIGHT,
STEPPER_BORDER_MEDIUM,
STEPPER_BORDER_STRONG,
STEPPER_BORDER_SUBTLE,
STEPPER_FONT,
STEPPER_TEXT,
STEPPER_TEXT_SECONDARY,
STEPPER_TEXT_TERTIARY,
STEPPER_TINT,
} from './stepper-visual-tokens';
const ACCENT = '#3e63dd';
const GLASS = 'rgba(255,255,255,0.9)';
const R = '3px';
export const Canvas = styled.div`
font-family: ${STEPPER_FONT};
height: 100%;
position: relative;
width: 100%;
`;
export const MainCard = styled.div`
background: white;
border-radius: 4px;
height: 84%;
left: 16%;
overflow: hidden;
position: absolute;
top: 8%;
width: 72%;
`;
export const BlueHeader = styled.div`
align-items: center;
background: ${ACCENT};
display: flex;
height: 32px;
justify-content: space-between;
padding: 0 10px;
width: 100%;
`;
export const HeaderLeft = styled.span`
color: white;
display: flex;
`;
export const HeaderCenter = styled.div`
align-items: center;
display: flex;
gap: 4px;
`;
export const HeaderTitle = styled.span`
color: white;
font-size: 10px;
font-weight: 500;
`;
export const HeaderSave = styled.span`
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: ${R};
color: white;
display: flex;
font-size: 8px;
font-weight: 500;
gap: 3px;
padding: 2px 8px;
`;
export const WidgetPanel = styled.div`
backdrop-filter: blur(5px);
background: ${GLASS};
border: 0.8px solid ${ACCENT};
border-radius: ${R};
left: 50%;
max-height: 36%;
overflow: hidden;
padding: 6px;
position: absolute;
top: 20%;
width: 38%;
`;
export const WidgetInner = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
`;
export const WidgetTitle = styled.div`
color: ${STEPPER_TEXT};
font-size: 10px;
font-weight: 600;
padding: 2px 3px;
`;
export const WSectionLabel = styled.div`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 7px;
font-weight: 600;
padding: 0 3px;
`;
export const WRow = styled.div`
align-items: center;
display: flex;
gap: 3px;
min-height: 16px;
padding: 1px 3px;
`;
export const WIcon = styled.span`
align-items: center;
color: ${STEPPER_TEXT_TERTIARY};
display: flex;
flex-shrink: 0;
height: 10px;
justify-content: center;
width: 10px;
`;
export const WLabel = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
flex-shrink: 0;
font-size: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 58px;
`;
export const WValue = styled.span`
color: ${STEPPER_TEXT};
flex: 1;
font-size: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const WChip = styled.span`
background: rgba(0, 0, 0, 0.02);
border: 0.5px solid ${STEPPER_BORDER_STRONG};
border-radius: 50px;
color: ${STEPPER_TEXT};
font-size: 8px;
padding: 1px 6px;
`;
export const NavPanel = styled.div`
backdrop-filter: blur(5px);
background: ${GLASS};
border: 0.8px solid ${ACCENT};
border-radius: ${R};
left: 10%;
max-height: 68%;
overflow-y: auto;
padding: 8px;
position: absolute;
top: 17%;
width: 30%;
`;
export const NavSectionLabel = styled.div`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 8px;
font-weight: 600;
padding: 4px 3px;
`;
export const NavItem = styled.div<{ $active: boolean }>`
align-items: center;
background: ${({ $active }) => ($active ? STEPPER_TINT : 'transparent')};
border-radius: ${R};
color: ${({ $active }) => ($active ? STEPPER_TEXT : STEPPER_TEXT_SECONDARY)};
cursor: pointer;
display: flex;
font-size: 9px;
font-weight: 500;
gap: 5px;
padding: 5px 4px;
`;
export const NavIconBox = styled.span<{ $bg: string }>`
align-items: center;
background: ${({ $bg }) => $bg};
border: 0.5px solid ${STEPPER_BORDER_STRONG};
border-radius: 3px;
display: flex;
flex-shrink: 0;
height: 13px;
justify-content: center;
width: 13px;
`;
export const NavSubItem = styled.div`
align-items: center;
color: ${STEPPER_TEXT_SECONDARY};
display: flex;
font-size: 8px;
font-weight: 500;
gap: 4px;
padding: 4px 4px 4px 16px;
`;
export const NavBreadcrumb = styled.span`
border-bottom: 0.5px solid ${STEPPER_BORDER_STRONG};
border-left: 0.5px solid ${STEPPER_BORDER_STRONG};
border-radius: 0 0 0 2px;
flex-shrink: 0;
height: 8px;
margin-left: -6px;
width: 4px;
`;
export const NavSuffix = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 6px;
`;
export const NavChevron = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 6px;
margin-left: auto;
`;
export const ActionsBar = styled.div`
backdrop-filter: blur(5px);
background: ${GLASS};
border: 0.8px solid ${ACCENT};
border-radius: ${R};
display: flex;
gap: 5px;
left: 54%;
padding: 5px 6px;
position: absolute;
top: 14%;
z-index: 3;
`;
export const ActionBtn = styled.span`
border: 0.8px solid ${STEPPER_BORDER_SUBTLE};
border-radius: ${R};
color: ${STEPPER_TEXT_SECONDARY};
font-size: 8px;
font-weight: 500;
padding: 3px 6px;
`;
export const RightPanel = styled.div`
backdrop-filter: blur(5px);
background: ${GLASS};
border: 0.8px solid #4a38f5;
border-radius: ${R};
bottom: 3%;
display: flex;
flex-direction: column;
left: 42%;
overflow: hidden;
position: absolute;
top: 40%;
width: 54%;
z-index: 4;
`;
export const RPHeader = styled.div`
align-items: center;
border-bottom: 0.8px solid ${STEPPER_BORDER_MEDIUM};
display: flex;
flex-shrink: 0;
gap: 2px;
padding: 6px;
`;
export const RPBackBtn = styled.span`
align-items: center;
color: ${STEPPER_TEXT_TERTIARY};
cursor: pointer;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
`;
export const RPIconBox = styled.span`
align-items: center;
background: ${STEPPER_TINT};
border-radius: 3px;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
`;
export const RPTitleGroup = styled.div`
align-items: baseline;
display: flex;
flex: 1;
gap: 3px;
min-width: 0;
`;
export const RPTitleBold = styled.span`
color: ${STEPPER_TEXT};
font-size: 9px;
font-weight: 600;
`;
export const RPTitleSub = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const RPSubBar = styled.div`
align-items: center;
border-bottom: 0.8px solid ${STEPPER_BORDER_LIGHT};
display: flex;
flex-shrink: 0;
gap: 3px;
padding: 5px 6px;
`;
export const RPSubLabel = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 8px;
font-weight: 500;
`;
export const RPFields = styled.div`
display: flex;
flex-direction: column;
gap: 1px;
overflow-y: auto;
padding: 6px;
`;
export const RPSectionRow = styled.div`
align-items: center;
display: flex;
gap: 5px;
padding: 3px;
`;
export const RPSectionName = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
flex: 1;
font-size: 7px;
font-weight: 600;
`;
export const RPEditable = styled.div`
align-items: center;
background: white;
border: 1px solid ${ACCENT};
border-radius: ${R};
display: flex;
gap: 4px;
margin: 2px 0 4px;
padding: 4px 6px;
`;
export const RPEditText = styled.span`
color: ${STEPPER_TEXT};
flex: 1;
font-size: 9px;
`;
export const RPDoneBtn = styled.span`
background: ${ACCENT};
border-radius: 3px;
color: white;
cursor: pointer;
font-size: 7px;
font-weight: 600;
padding: 2px 8px;
`;
export const RPFieldRow = styled.div<{ $dragging: boolean }>`
align-items: center;
background: ${({ $dragging }) =>
$dragging ? 'rgba(59,130,246,0.04)' : 'transparent'};
border-radius: ${R};
cursor: grab;
display: flex;
gap: 5px;
padding: 3px;
touch-action: none;
&:active {
cursor: grabbing;
}
`;
export const RPFieldIconBox = styled.span`
align-items: center;
background: ${STEPPER_TINT};
border-radius: 3px;
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
width: 16px;
`;
export const RPFieldLabels = styled.div`
align-items: baseline;
display: flex;
flex: 1;
font-size: 8px;
gap: 3px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
`;
export const RPFieldName = styled.span`
color: ${STEPPER_TEXT_SECONDARY};
flex-shrink: 0;
`;
export const RPFieldDot = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
`;
export const RPFieldType = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
overflow: hidden;
text-overflow: ellipsis;
`;
export const RPActionBtn = styled.span`
cursor: pointer;
display: flex;
`;
export const RPAddSection = styled.div`
align-items: center;
cursor: pointer;
display: flex;
gap: 5px;
padding: 3px;
`;
export const RPAddIconBox = styled.span`
align-items: center;
background: ${STEPPER_TINT};
border-radius: 3px;
display: flex;
height: 16px;
justify-content: center;
width: 16px;
`;
export const RPAddText = styled.span`
color: ${STEPPER_TEXT_SECONDARY};
font-size: 8px;
`;
export const RPNewFields = styled.div`
align-items: center;
border-top: 0.8px solid ${STEPPER_BORDER_MEDIUM};
display: flex;
gap: 5px;
margin-top: 4px;
padding: 6px 3px 3px;
`;
export const RPNewTitle = styled.span`
color: ${STEPPER_TEXT_SECONDARY};
font-size: 8px;
`;
export const RPNewDesc = styled.span`
color: ${STEPPER_TEXT_TERTIARY};
font-size: 7px;
`;

View File

@@ -0,0 +1,19 @@
export const STEPPER_BG = '#ffffff';
export const STEPPER_CARD_BG = '#fcfcfc';
export const STEPPER_TEXT = '#333';
export const STEPPER_TEXT_SECONDARY = '#6b7280';
export const STEPPER_TEXT_TERTIARY = '#9ca3af';
export const STEPPER_TEXT_MUTED = '#666';
export const STEPPER_BORDER_MEDIUM = '#ebebeb';
export const STEPPER_BORDER_STRONG = '#d6d6d6';
export const STEPPER_BORDER_LIGHT = '#f1f1f1';
export const STEPPER_BORDER_SUBTLE = 'rgba(0, 0, 0, 0.08)';
export const STEPPER_FONT = "'Inter', sans-serif";
export const STEPPER_SHADOW_SM = '0 1px 6px rgba(0, 0, 0, 0.05)';
export const STEPPER_TINT = 'rgba(0, 0, 0, 0.04)';
export const STEPPER_HEADER_BG = '#d9e2fc';
export const STEPPER_HEADER_BORDER = '#c6d4f9';

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react';
import { ANIMATION_SEQUENCE, STEP_INTERVAL_MS } from './data/workflow.data';
export function useWorkflowAnimation(active: boolean) {
const [activeNodes, setActiveNodes] = useState<Set<string>>(new Set());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stepRef = useRef(0);
useEffect(() => {
if (!active) {
setActiveNodes(new Set());
stepRef.current = 0;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
intervalRef.current = setInterval(() => {
stepRef.current += 1;
if (stepRef.current > ANIMATION_SEQUENCE.length) {
stepRef.current = 0;
setActiveNodes(new Set());
} else {
setActiveNodes(new Set(ANIMATION_SEQUENCE.slice(0, stepRef.current)));
}
}, STEP_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [active]);
return activeNodes;
}