Frameless ProductStepper with twenty-front-faithful scenes (#22197)

## What

Reworks the website's **ProductStepper** (the scroll-driven *Data model
/ Automation / Layout* section) to be frameless and to render all three
scenes faithfully to twenty-front — real icons, labels, colors,
structure, and connectors.

## Changes

**Frameless + scaling**
- Removed the shared white card/header; the three scenes now sit
directly on the dark dot-grid stage.
- Unified every scene on one `StageFit` primitive (fixed design box →
scaled to fit), pixel-identical at full width and uniform on smaller
screens.

**Data model scene**
- Real object schema (3 Standard + 2 Custom objects, real relation
fields); replaced an invented "Investors" object with the real
**Employment History** custom object (`IconBriefcase`, Company + Person
relations) from the server seed.
- Clean spanning-tree connections; removed the Standard/Custom badge;
fixed card sizing + edge centering.

**Workflow scene**
- Real action labels + a logical flow: *Record is Created → Filter →
Search Records → AI Agent → (Update Record · Send Email · Create
Record)*. Dropped the iterator (a loop construct shown without a loop
body).
- Per-action icon colors matching twenty-front (trigger blue, flow
green, record gray, send-email red, AI agent pink) on a gray tile.
- Rebuilt the node to twenty-front's real anatomy and the connectors
(source circle → `getBezierPath` → arrow marker) verbatim.

**Layout scene**
- Real workspace sidebar: real Tabler object icons, exact labels, the
true default sidebar (6 objects + Workflows folder),
`getIconTileColorShades` tile colors; dropped invented entries.
- Record overview + Fields editor with correct field-type labels (Links,
True/False, Date and Time); legibility + spacing tuning.

**Icons** — replaced every hand-drawn approximation with real
`@tabler/icons-react` / twenty-front object icons.

**Misc** — smoother step-to-step transitions (translate + easing
tokens).

## Testing
Marketing visual; `lint` / `typecheck` / `build` green, and each scene
reviewed visually against twenty-front.




https://github.com/user-attachments/assets/997e1b95-55c0-401a-93a4-c70545577057
This commit is contained in:
Abdullah.
2026-06-26 13:17:44 +05:00
committed by GitHub
parent b49225df4b
commit 85f64abb28
45 changed files with 1508 additions and 2192 deletions
@@ -0,0 +1,8 @@
export function getElementScale(element: HTMLElement): number {
const layoutWidth = element.offsetWidth;
if (layoutWidth === 0) {
return 1;
}
return element.getBoundingClientRect().width / layoutWidth;
}
@@ -7,6 +7,7 @@ export {
type ScrollProgressEffectProps,
} from './ScrollProgressEffect';
export { getReducedMotionSnapshot } from './reduced-motion-snapshot';
export { getElementScale } from './get-element-scale';
export { observeElementSize } from './observe-element-size';
export { useScaleToFit } from './use-scale-to-fit';
export { useMediaQuery } from './use-media-query';
@@ -7,6 +7,8 @@ import {
type PointerEvent as ReactPointerEvent,
} from 'react';
import { getElementScale } from './get-element-scale';
type Point = { x: number; y: number };
export type PointerDragPositions = {
@@ -44,6 +46,7 @@ export function usePointerDragPositions(
id: string;
positionX: number;
positionY: number;
scale: number;
startX: number;
startY: number;
} | null>(null);
@@ -79,6 +82,7 @@ export function usePointerDragPositions(
startY: event.clientY,
positionX: position.x,
positionY: position.y,
scale: getElementScale(event.currentTarget),
};
setDraggingId(id);
};
@@ -89,9 +93,9 @@ export function usePointerDragPositions(
if (!dragStart || event.pointerId !== captureRef.current?.pointerId) {
return;
}
const { id, startX, startY, positionX, positionY } = dragStart;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
const { id, startX, startY, positionX, positionY, scale } = dragStart;
const deltaX = (event.clientX - startX) / scale;
const deltaY = (event.clientY - startY) / scale;
setPositions((previous) => ({
...previous,
[id]: { x: positionX + deltaX, y: positionY + deltaY },
@@ -0,0 +1,243 @@
'use client';
import { styled } from '@linaria/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useState } from 'react';
import { usePointerDragPositions } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
import { DATA_MODEL_ICONS } from './components/DataModelIcons';
import { DrawEdge } from './components/DrawEdge';
import { DATA_MODEL_GRAPH } from './data/data-model-data';
import { type EntityConnection } from './types/entity-connection';
const shell = PRODUCT_STEPPER_SCENE.shell;
const entityTones = PRODUCT_STEPPER_SCENE.entityTones;
const { Canvas, Shell, StageFit, SvgLayer } = STEPPER_SHELL_CHROME;
const EntityCard = styled.div`
backdrop-filter: blur(14px);
background: ${shell.cardBackground};
border: 1px solid ${shell.borderMedium};
border-radius: 8px;
box-shadow: ${PRODUCT_STEPPER_SCENE.cardShadow};
cursor: grab;
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px;
position: absolute;
touch-action: none;
transition: border-color 0.15s ease;
width: 180px;
z-index: 2;
&[data-hovered] {
border-color: ${shell.borderStrong};
}
&: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 ${shell.borderLight};
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
padding: 4px 0;
`;
const EntityIcon = styled.span<{ $tint: string; $line: string }>`
align-items: center;
background: ${({ $tint }) => $tint};
border: 1px solid ${({ $line }) => $line};
border-radius: 3px;
color: ${shell.text};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const EntityLabel = styled.span`
color: ${shell.text};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
const EntityMeta = styled.span`
color: ${shell.textTertiary};
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
`;
const FieldRow = styled.div`
align-items: center;
color: ${shell.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;
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const ExpandHint = styled.div`
align-items: center;
color: ${shell.textTertiary};
display: flex;
font-size: 12px;
font-weight: 400;
gap: 4px;
height: 22px;
line-height: 1.4;
padding: 0 6px;
`;
const CARD_WIDTH = 180;
const CARD_HEIGHT_ESTIMATE = 110;
type EntityPositions = Record<string, { x: number; y: number }>;
function getCardCenter(
positions: EntityPositions,
entityId: string,
): { x: number; y: number } {
const position = positions[entityId];
if (!position) {
return { x: 0, y: 0 };
}
return {
x: position.x + CARD_WIDTH / 2,
y: position.y + CARD_HEIGHT_ESTIMATE / 2,
};
}
export function DataModelVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
draggingId: dragging,
handlePointerDown,
positions,
} = usePointerDragPositions(() =>
Object.fromEntries(
DATA_MODEL_GRAPH.entities.map((entity) => [
entity.id,
{ x: entity.x, y: entity.y },
]),
),
);
const [hoveredEntity, setHoveredEntity] = useState<string | null>(null);
const { i18n } = useLingui();
const isConnectionHighlighted = (connection: EntityConnection) =>
hoveredEntity === connection.from || hoveredEntity === connection.to;
return (
<Shell active={active}>
<Canvas {...canvasHandlers}>
<StageFit designHeight={530} designWidth={560}>
<SvgLayer>
{DATA_MODEL_GRAPH.connections.map((connection) => (
<DrawEdge
circleRadius={1.5}
elbow="horizontal-first"
from={getCardCenter(positions, connection.from)}
highlighted={isConnectionHighlighted(connection)}
key={`${connection.from}-${connection.to}`}
to={getCardCenter(positions, connection.to)}
/>
))}
</SvgLayer>
{DATA_MODEL_GRAPH.entities.map((entity) => {
const position = positions[entity.id];
const HeaderIcon = DATA_MODEL_ICONS.headers[entity.headerIcon];
const tone = entityTones[entity.tone];
return (
<EntityCard
data-hovered={
hoveredEntity === entity.id || dragging === entity.id
? ''
: undefined
}
key={entity.id}
onPointerDown={(event) => handlePointerDown(entity.id, event)}
onPointerEnter={() => setHoveredEntity(entity.id)}
onPointerLeave={() => setHoveredEntity(null)}
style={{
left: position.x,
top: position.y,
transition:
dragging === entity.id ? 'none' : 'border-color 0.15s',
}}
>
<EntityHeader>
<EntityIcon $line={tone.border} $tint={tone.background}>
<HeaderIcon />
</EntityIcon>
<EntityLabel>{i18n._(entity.label)}</EntityLabel>
<EntityMeta>· {entity.meta}</EntityMeta>
</EntityHeader>
<InnerCard>
{entity.fields.map((field) => {
const Icon = DATA_MODEL_ICONS.fields[field.icon];
return (
<FieldRow key={field.id}>
<FieldIcon>
<Icon />
</FieldIcon>
{i18n._(field.label)}
</FieldRow>
);
})}
<ExpandHint>
<FieldIcon>
<DATA_MODEL_ICONS.ChevronExpand />
</FieldIcon>
{entity.expandCount} {i18n._(msg`fields`)}
</ExpandHint>
</InnerCard>
</EntityCard>
);
})}
</StageFit>
</Canvas>
</Shell>
);
}
@@ -1,17 +1,3 @@
export type DataModelFieldIcon =
| 'apps'
| 'building'
| 'tag'
| 'target'
| 'user';
export type DataModelHeaderIcon =
| 'buildingSmall'
| 'targetSmall'
| 'userScreenSmall'
| 'userSmall'
| 'usersSmall';
function IconBuilding() {
return (
<svg
@@ -112,6 +98,26 @@ function IconTarget() {
);
}
function IconBriefcaseSmall() {
return (
<svg
fill="none"
height={9}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
viewBox="0 0 24 24"
width={9}
>
<path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" />
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2" />
<path d="M12 12l0 .01" />
<path d="M3 13a20 20 0 0 0 18 0" />
</svg>
);
}
function IconBuildingSmall() {
return (
<svg
@@ -225,8 +231,7 @@ function IconChevronExpand() {
viewBox="0 0 24 24"
width={12}
>
<path d="M6 9l6 -6" />
<path d="M6 9l-6 -6" />
<path d="M6 9l6 6l6 -6" />
</svg>
);
}
@@ -241,6 +246,7 @@ export const DATA_MODEL_ICONS = {
user: IconUser,
},
headers: {
briefcaseSmall: IconBriefcaseSmall,
buildingSmall: IconBuildingSmall,
targetSmall: IconTargetSmall,
userScreenSmall: IconUserScreenSmall,
@@ -0,0 +1,93 @@
import { msg } from '@lingui/core/macro';
import { type EntityConnection } from '../types/entity-connection';
import { type EntityDefinition } from '../types/entity-definition';
export const DATA_MODEL_GRAPH: {
connections: EntityConnection[];
entities: EntityDefinition[];
} = {
entities: [
{
id: 'people',
label: msg`People`,
meta: '840',
isCustom: false,
headerIcon: 'userSmall',
tone: 'green',
fields: [
{ id: 'company', icon: 'building', label: msg`Company` },
{ id: 'opportunities', icon: 'target', label: msg`Opportunities` },
],
expandCount: 22,
x: 40,
y: 40,
},
{
id: 'companies',
label: msg`Companies`,
meta: '120',
isCustom: false,
headerIcon: 'buildingSmall',
tone: 'indigo',
fields: [
{ id: 'people', icon: 'user', label: msg`People` },
{ id: 'opportunities', icon: 'target', label: msg`Opportunities` },
],
expandCount: 39,
x: 330,
y: 20,
},
{
id: 'employment-history',
label: msg`Employment History`,
meta: '48',
isCustom: true,
headerIcon: 'briefcaseSmall',
tone: 'purple',
fields: [
{ id: 'company', icon: 'building', label: msg`Company` },
{ id: 'person', icon: 'user', label: msg`Person` },
],
expandCount: 8,
x: 40,
y: 310,
},
{
id: 'opportunities',
label: msg`Opportunities`,
meta: '64',
isCustom: false,
headerIcon: 'targetSmall',
tone: 'red',
fields: [
{ id: 'company', icon: 'building', label: msg`Company` },
{ id: 'point-of-contact', icon: 'user', label: msg`Point of Contact` },
],
expandCount: 11,
x: 380,
y: 190,
},
{
id: 'demos',
label: msg`Demos`,
meta: '45',
isCustom: true,
headerIcon: 'userScreenSmall',
tone: 'indigo',
fields: [
{ id: 'company', icon: 'building', label: msg`Company` },
{ id: 'opportunity', icon: 'target', label: msg`Opportunity` },
],
expandCount: 6,
x: 280,
y: 400,
},
],
connections: [
{ from: 'people', to: 'companies' },
{ from: 'companies', to: 'opportunities' },
{ from: 'opportunities', to: 'demos' },
{ from: 'people', to: 'employment-history' },
],
};
@@ -0,0 +1 @@
export { DataModelVisual } from './DataModelVisual';
@@ -0,0 +1,6 @@
export type DataModelFieldIcon =
| 'apps'
| 'building'
| 'tag'
| 'target'
| 'user';
@@ -0,0 +1,7 @@
export type DataModelHeaderIcon =
| 'briefcaseSmall'
| 'buildingSmall'
| 'targetSmall'
| 'userScreenSmall'
| 'userSmall'
| 'usersSmall';
@@ -0,0 +1,4 @@
export type EntityConnection = {
from: string;
to: string;
};
@@ -0,0 +1,18 @@
import { type MessageDescriptor } from '@lingui/core';
import { type DataModelFieldIcon } from './data-model-field-icon';
import { type DataModelHeaderIcon } from './data-model-header-icon';
import { type EntityTone } from './entity-tone';
export type EntityDefinition = {
expandCount: number;
fields: { icon: DataModelFieldIcon; id: string; label: MessageDescriptor }[];
headerIcon: DataModelHeaderIcon;
id: string;
isCustom: boolean;
label: MessageDescriptor;
meta: string;
tone: EntityTone;
x: number;
y: number;
};
@@ -0,0 +1,3 @@
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
export type EntityTone = keyof typeof PRODUCT_STEPPER_SCENE.entityTones;
@@ -0,0 +1,354 @@
'use client';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import {
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { getElementScale } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
import { LAYOUT_GLYPHS } from './components/LayoutIcons';
import { LAYOUT_CHROME } from './components/layout-chrome';
import { LAYOUT_EDITOR_CONTENT } from './data/layout-data';
const {
ChevronLeft: ChevronLeftGlyph,
Dots: DotsGlyph,
Eye: EyeGlyph,
Field: FieldGlyph,
Grip: GripGlyph,
List: ListGlyph,
Nav: NavGlyph,
NewSection: NewSectionGlyph,
Plus: PlusGlyph,
Spark: SparkGlyph,
} = LAYOUT_GLYPHS;
const {
ActionButton,
ActionsBar,
AddIconBox,
AddSectionRow,
AddText,
DoneButton,
EditableRow,
EditableText,
FieldDot,
FieldIconBox,
FieldLabels,
FieldName,
FieldRow,
FieldType,
NavBreadcrumb,
NavChevron,
NavIconBox,
NavItem,
NavPanel,
NavSectionLabel,
NavSubItem,
NewFieldsColumn,
NewFieldsDescription,
NewFieldsRow,
NewFieldsTitle,
PanelActionButton,
PanelBackButton,
PanelFields,
PanelHeader,
PanelIconBox,
PanelSubBar,
PanelSubLabel,
PanelTitleBold,
PanelTitleGroup,
PanelTitleSub,
RightPanel,
SectionName,
SectionRow,
WidgetChip,
WidgetIcon,
WidgetInner,
WidgetLabel,
WidgetPanel,
WidgetRow,
WidgetSectionLabel,
WidgetValue,
} = LAYOUT_CHROME;
const { Canvas, Shell, StageFit } = STEPPER_SHELL_CHROME;
const LAYOUT_DESIGN = { height: 496, width: 473 };
const ROW_STEP_PX = 22;
export function LayoutVisual({ active }: { active: boolean }) {
const [fields, setFields] = useState(LAYOUT_EDITOR_CONTENT.fields);
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartY = useRef(0);
const dragScale = useRef(1);
const { i18n } = useLingui();
const toggleVisibility = (fieldId: string) => {
setFields((previous) =>
previous.map((field) =>
field.id === fieldId ? { ...field, visible: !field.visible } : field,
),
);
};
const handleDragStart = (
fieldId: string,
event: ReactPointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDraggingId(fieldId);
dragStartY.current = event.clientY;
dragScale.current = getElementScale(event.currentTarget);
};
const handleDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingId) {
return;
}
const deltaY = (event.clientY - dragStartY.current) / dragScale.current;
const steps = Math.round(deltaY / ROW_STEP_PX);
if (steps === 0) {
return;
}
setFields((previous) => {
const index = previous.findIndex((field) => field.id === draggingId);
if (index === -1) {
return previous;
}
const target = Math.max(0, Math.min(previous.length - 1, index + steps));
if (target === index) {
return previous;
}
const next = [...previous];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
return next;
});
dragStartY.current = event.clientY;
};
const handleDragEnd = () => setDraggingId(null);
const handleLostCapture = () => {
if (draggingId) {
setDraggingId(null);
}
};
const sections = [...new Set(fields.map((field) => field.section))];
return (
<Shell active={active}>
<Canvas>
<StageFit
baseScale={1.35}
designHeight={LAYOUT_DESIGN.height}
designWidth={LAYOUT_DESIGN.width}
zoom={1.05}
>
<WidgetPanel>
<WidgetInner>
<WidgetSectionLabel>{i18n._(msg`General`)}</WidgetSectionLabel>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="link" />
</WidgetIcon>
<WidgetLabel>{i18n._(msg`URL`)}</WidgetLabel>
<WidgetChip>anthropic.com</WidgetChip>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="user" />
</WidgetIcon>
<WidgetLabel>{i18n._(msg`Account Owner`)}</WidgetLabel>
<WidgetValue>Félix Malfait</WidgetValue>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="map" />
</WidgetIcon>
<WidgetLabel>{i18n._(msg`Address`)}</WidgetLabel>
<WidgetValue>548 Market St, San Fr...</WidgetValue>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="target" />
</WidgetIcon>
<WidgetLabel>{i18n._(msg`ICP`)}</WidgetLabel>
<WidgetValue> True</WidgetValue>
</WidgetRow>
</WidgetInner>
</WidgetPanel>
<NavPanel>
<NavSectionLabel>{i18n._(msg`Workspace`)}</NavSectionLabel>
{LAYOUT_EDITOR_CONTENT.navItems.map((item) => (
<div key={item.icon}>
<NavItem data-active={item.isActive ? '' : undefined}>
<NavIconBox
$background={
PRODUCT_STEPPER_SCENE.navTiles[item.color].background
}
$border={PRODUCT_STEPPER_SCENE.navTiles[item.color].border}
>
<NavGlyph
color={PRODUCT_STEPPER_SCENE.navTiles[item.color].icon}
type={item.icon}
/>
</NavIconBox>
{i18n._(item.label)}
{item.isFolder ? <NavChevron></NavChevron> : null}
</NavItem>
{item.children?.map((child) => (
<NavSubItem key={child.icon}>
<NavBreadcrumb />
<NavIconBox
$background={
PRODUCT_STEPPER_SCENE.navTiles[child.color].background
}
$border={
PRODUCT_STEPPER_SCENE.navTiles[child.color].border
}
>
<NavGlyph
color={PRODUCT_STEPPER_SCENE.navTiles[child.color].icon}
type={child.icon}
/>
</NavIconBox>
{i18n._(child.label)}
</NavSubItem>
))}
</div>
))}
</NavPanel>
<ActionsBar>
<ActionButton>+ {i18n._(msg`New record`)}</ActionButton>
<ActionButton> {i18n._(msg`Enrich`)}</ActionButton>
<ActionButton> {i18n._(msg`Edit actions`)}</ActionButton>
</ActionsBar>
<RightPanel
onLostPointerCapture={handleLostCapture}
onPointerCancel={handleDragEnd}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
<PanelHeader>
<PanelBackButton>
<ChevronLeftGlyph />
</PanelBackButton>
<PanelIconBox>
<ListGlyph />
</PanelIconBox>
<PanelTitleGroup>
<PanelTitleBold>{i18n._(msg`Fields`)}</PanelTitleBold>
<PanelTitleSub>{i18n._(msg`Fields widget`)}</PanelTitleSub>
</PanelTitleGroup>
<PanelActionButton>
<SparkGlyph />
</PanelActionButton>
</PanelHeader>
<PanelSubBar>
<PanelBackButton>
<ChevronLeftGlyph />
</PanelBackButton>
<PanelSubLabel>{i18n._(msg`Layout`)}</PanelSubLabel>
</PanelSubBar>
<PanelFields>
{sections.map((section, sectionNumber) => (
<div key={section}>
<SectionRow>
<GripGlyph />
<SectionName>
{i18n._(LAYOUT_EDITOR_CONTENT.sectionLabels[section])}
</SectionName>
</SectionRow>
{sectionNumber === 0 ? (
<EditableRow>
<EditableText>{i18n._(msg`Industry`)}</EditableText>
<DoneButton>{i18n._(msg`Done`)}</DoneButton>
</EditableRow>
) : null}
{fields
.filter((field) => field.section === section)
.map((field) => (
<FieldRow
data-dragging={draggingId === field.id ? '' : undefined}
key={field.id}
onPointerDown={(event) =>
handleDragStart(field.id, event)
}
>
<FieldIconBox>
<FieldGlyph type={field.icon} />
</FieldIconBox>
<FieldLabels>
<FieldName>{i18n._(field.label)}</FieldName>
<FieldDot>·</FieldDot>
<FieldType>{i18n._(field.type)}</FieldType>
</FieldLabels>
<PanelActionButton
onClick={(event) => {
event.stopPropagation();
toggleVisibility(field.id);
}}
>
<EyeGlyph visible={field.visible} />
</PanelActionButton>
<PanelActionButton>
<DotsGlyph />
</PanelActionButton>
</FieldRow>
))}
{sectionNumber > 0 && sectionNumber < sections.length - 1 ? (
<AddSectionRow>
<AddIconBox>
<NewSectionGlyph />
</AddIconBox>
<AddText>{i18n._(msg`Add a Section`)}</AddText>
</AddSectionRow>
) : null}
</div>
))}
<NewFieldsRow>
<AddIconBox>
<PlusGlyph />
</AddIconBox>
<NewFieldsColumn>
<NewFieldsTitle>{i18n._(msg`New fields`)}</NewFieldsTitle>
<NewFieldsDescription>
{i18n._(msg`Default position/visibility for field…`)}
</NewFieldsDescription>
</NewFieldsColumn>
</NewFieldsRow>
<AddSectionRow>
<AddIconBox>
<NewSectionGlyph />
</AddIconBox>
<AddText>{i18n._(msg`Add a Section`)}</AddText>
</AddSectionRow>
</PanelFields>
</RightPanel>
</StageFit>
</Canvas>
</Shell>
);
}
@@ -0,0 +1,113 @@
import {
IconBuildingSkyscraper,
IconCalendar,
IconCheckbox,
IconChevronLeft,
IconCurrencyDollar,
IconDotsVertical,
IconEye,
IconEyeOff,
IconGripVertical,
IconHistoryToggle,
IconLayoutDashboard,
IconLink,
IconList,
IconMapPin,
IconNotes,
IconPlus,
IconSettingsAutomation,
IconSparkles,
IconTargetArrow,
IconUser,
IconUserCircle,
IconUsers,
IconVersions,
} from '@tabler/icons-react';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { type LayoutFieldIconType } from '../types/layout-field-icon-type';
import { type LayoutNavIconType } from '../types/layout-nav-icon-type';
const inks = PRODUCT_STEPPER_SCENE.layout;
const FIELD_ICONS: Record<LayoutFieldIconType, typeof IconLink> = {
calendar: IconCalendar,
link: IconLink,
map: IconMapPin,
money: IconCurrencyDollar,
target: IconTargetArrow,
user: IconUserCircle,
users: IconUsers,
};
const NAV_ICONS: Record<LayoutNavIconType, typeof IconLink> = {
automation: IconSettingsAutomation,
building: IconBuildingSkyscraper,
checkbox: IconCheckbox,
dashboard: IconLayoutDashboard,
history: IconHistoryToggle,
notes: IconNotes,
target: IconTargetArrow,
user: IconUser,
versions: IconVersions,
};
function FieldGlyph({ type }: { type: LayoutFieldIconType }) {
const IconComponent = FIELD_ICONS[type];
return <IconComponent color={inks.fieldInk} size={12} stroke={1.6} />;
}
function NavGlyph({ color, type }: { color: string; type: LayoutNavIconType }) {
const IconComponent = NAV_ICONS[type];
return <IconComponent color={color} size={11} stroke={1.8} />;
}
function EyeGlyph({ visible }: { visible: boolean }) {
return visible ? (
<IconEye color={inks.eyeInk} size={11} stroke={1.6} />
) : (
<IconEyeOff color={inks.eyeHiddenInk} size={11} stroke={1.6} />
);
}
function ChevronLeftGlyph() {
return <IconChevronLeft color={inks.eyeInk} size={12} stroke={1.6} />;
}
function DotsGlyph() {
return <IconDotsVertical color={inks.eyeInk} size={12} stroke={1.8} />;
}
function GripGlyph() {
return <IconGripVertical color={inks.eyeInk} size={12} stroke={1.8} />;
}
function ListGlyph() {
return <IconList color={inks.fieldInk} size={11} stroke={1.6} />;
}
function SparkGlyph() {
return <IconSparkles color={inks.eyeInk} size={11} stroke={1.6} />;
}
function NewSectionGlyph() {
return <IconPlus color={inks.fieldInk} size={11} stroke={1.6} />;
}
function PlusGlyph() {
return <IconPlus color={inks.fieldInk} size={11} stroke={1.6} />;
}
export const LAYOUT_GLYPHS = {
ChevronLeft: ChevronLeftGlyph,
Dots: DotsGlyph,
Eye: EyeGlyph,
Field: FieldGlyph,
Grip: GripGlyph,
List: ListGlyph,
Nav: NavGlyph,
NewSection: NewSectionGlyph,
Plus: PlusGlyph,
Spark: SparkGlyph,
};
@@ -7,80 +7,17 @@ const layout = PRODUCT_STEPPER_SCENE.layout;
const PANEL_RADIUS = '3px';
const Canvas = styled.div`
font-family: ${shell.font};
height: 100%;
opacity: 0.6;
position: relative;
transition: opacity 0.3s;
width: 100%;
&[data-active] {
opacity: 1;
}
`;
const MainCard = styled.div`
background: white;
border-radius: 4px;
height: 84%;
left: 16%;
overflow: hidden;
position: absolute;
top: 8%;
width: 72%;
`;
const BlueHeader = styled.div`
align-items: center;
background: ${layout.accent};
display: flex;
height: 32px;
justify-content: space-between;
padding: 0 10px;
width: 100%;
`;
const HeaderLeft = styled.span`
color: white;
display: flex;
`;
const HeaderCenter = styled.div`
align-items: center;
display: flex;
gap: 4px;
`;
const HeaderTitle = styled.span`
color: white;
font-size: 10px;
font-weight: 500;
`;
const HeaderSave = styled.span`
align-items: center;
border: 1px solid ${layout.saveBorder};
border-radius: ${PANEL_RADIUS};
color: white;
display: flex;
font-size: 8px;
font-weight: 500;
gap: 3px;
padding: 2px 8px;
`;
const WidgetPanel = styled.div`
backdrop-filter: blur(5px);
background: ${layout.glass};
background: ${shell.cardBackground};
border: 0.8px solid ${layout.accent};
border-radius: ${PANEL_RADIUS};
left: 50%;
left: 48%;
max-height: 36%;
overflow: hidden;
padding: 6px;
position: absolute;
top: 20%;
top: 13%;
width: 38%;
`;
@@ -90,13 +27,6 @@ const WidgetInner = styled.div`
gap: 5px;
`;
const WidgetTitle = styled.div`
color: ${shell.text};
font-size: 10px;
font-weight: 600;
padding: 2px 3px;
`;
const WidgetSectionLabel = styled.div`
color: ${shell.textTertiary};
font-size: 7px;
@@ -153,15 +83,15 @@ const WidgetChip = styled.span`
const NavPanel = styled.div`
backdrop-filter: blur(5px);
background: ${layout.glass};
background: ${shell.cardBackground};
border: 0.8px solid ${layout.accent};
border-radius: ${PANEL_RADIUS};
left: 10%;
left: 8%;
max-height: 68%;
overflow-y: auto;
padding: 8px;
position: absolute;
top: 17%;
top: 13%;
width: 30%;
`;
@@ -190,10 +120,10 @@ const NavItem = styled.div`
}
`;
const NavIconBox = styled.span<{ $tint: string }>`
const NavIconBox = styled.span<{ $background: string; $border: string }>`
align-items: center;
background: ${({ $tint }) => $tint};
border: 0.5px solid ${shell.borderStrong};
background: ${({ $background }) => $background};
border: 0.5px solid ${({ $border }) => $border};
border-radius: 3px;
display: flex;
flex-shrink: 0;
@@ -222,11 +152,6 @@ const NavBreadcrumb = styled.span`
width: 4px;
`;
const NavSuffix = styled.span`
color: ${shell.textTertiary};
font-size: 6px;
`;
const NavChevron = styled.span`
color: ${shell.textTertiary};
font-size: 6px;
@@ -235,15 +160,15 @@ const NavChevron = styled.span`
const ActionsBar = styled.div`
backdrop-filter: blur(5px);
background: ${layout.glass};
background: ${shell.cardBackground};
border: 0.8px solid ${layout.accent};
border-radius: ${PANEL_RADIUS};
display: flex;
gap: 5px;
left: 54%;
left: 46%;
padding: 5px 6px;
position: absolute;
top: 14%;
top: 6%;
z-index: 3;
`;
@@ -258,7 +183,7 @@ const ActionButton = styled.span`
const RightPanel = styled.div`
backdrop-filter: blur(5px);
background: ${layout.glass};
background: ${shell.cardBackground};
border: 0.8px solid ${layout.panelAccent};
border-radius: ${PANEL_RADIUS};
bottom: 3%;
@@ -267,7 +192,7 @@ const RightPanel = styled.div`
left: 42%;
overflow: hidden;
position: absolute;
top: 40%;
top: 36%;
width: 54%;
z-index: 4;
`;
@@ -501,8 +426,6 @@ export const LAYOUT_CHROME = {
AddIconBox,
AddSectionRow,
AddText,
BlueHeader,
Canvas,
DoneButton,
EditableRow,
EditableText,
@@ -512,11 +435,6 @@ export const LAYOUT_CHROME = {
FieldName,
FieldRow,
FieldType,
HeaderCenter,
HeaderLeft,
HeaderSave,
HeaderTitle,
MainCard,
NavBreadcrumb,
NavChevron,
NavIconBox,
@@ -524,7 +442,6 @@ export const LAYOUT_CHROME = {
NavPanel,
NavSectionLabel,
NavSubItem,
NavSuffix,
NewFieldsColumn,
NewFieldsDescription,
NewFieldsRow,
@@ -549,6 +466,5 @@ export const LAYOUT_CHROME = {
WidgetPanel,
WidgetRow,
WidgetSectionLabel,
WidgetTitle,
WidgetValue,
};
@@ -0,0 +1,110 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type LayoutFieldDefinition } from '../types/layout-field-definition';
import { type LayoutNavItemDefinition } from '../types/layout-nav-item-definition';
export const LAYOUT_EDITOR_CONTENT: {
fields: LayoutFieldDefinition[];
navItems: LayoutNavItemDefinition[];
sectionLabels: Record<string, MessageDescriptor>;
} = {
fields: [
{
id: 'url',
icon: 'link',
label: msg`URL`,
type: msg`Links`,
section: 'General',
visible: true,
},
{
id: 'account-owner',
icon: 'user',
label: msg`Account Owner`,
type: msg`Relation`,
section: 'General',
visible: true,
},
{
id: 'revenue',
icon: 'money',
label: msg`Revenue`,
type: msg`Currency`,
section: 'General',
visible: true,
},
{
id: 'icp',
icon: 'target',
label: msg`ICP`,
type: msg`True/False`,
section: 'Additional',
visible: false,
},
{
id: 'employees',
icon: 'users',
label: msg`Employees`,
type: msg`Number`,
section: 'Other',
visible: true,
},
{
id: 'address',
icon: 'map',
label: msg`Address`,
type: msg`Address`,
section: 'Other',
visible: true,
},
{
id: 'creation-date',
icon: 'calendar',
label: msg`Creation date`,
type: msg`Date and Time`,
section: 'Other',
visible: true,
},
],
navItems: [
{ icon: 'building', label: msg`Companies`, isActive: true, color: 'blue' },
{ icon: 'user', label: msg`People`, isActive: false, color: 'blue' },
{
icon: 'target',
label: msg`Opportunities`,
isActive: false,
color: 'red',
},
{
icon: 'checkbox',
label: msg`Tasks`,
isActive: false,
color: 'turquoise',
},
{ icon: 'notes', label: msg`Notes`, isActive: false, color: 'turquoise' },
{
icon: 'dashboard',
label: msg`Dashboards`,
isActive: false,
color: 'gray',
},
{
icon: 'automation',
label: msg`Workflows`,
isActive: false,
color: 'orange',
isFolder: true,
children: [
{ icon: 'automation', label: msg`Workflows`, color: 'gray' },
{ icon: 'history', label: msg`Workflow Runs`, color: 'gray' },
{ icon: 'versions', label: msg`Workflow Versions`, color: 'gray' },
],
},
],
sectionLabels: {
Additional: msg`Additional`,
General: msg`General`,
Other: msg`Other`,
},
};
@@ -0,0 +1 @@
export { LayoutVisual } from './LayoutVisual';
@@ -0,0 +1,12 @@
import { type MessageDescriptor } from '@lingui/core';
import { type LayoutFieldIconType } from './layout-field-icon-type';
export type LayoutFieldDefinition = {
icon: LayoutFieldIconType;
id: string;
label: MessageDescriptor;
section: string;
type: MessageDescriptor;
visible: boolean;
};
@@ -0,0 +1,8 @@
export type LayoutFieldIconType =
| 'calendar'
| 'link'
| 'map'
| 'money'
| 'target'
| 'user'
| 'users';
@@ -0,0 +1,10 @@
export type LayoutNavIconType =
| 'automation'
| 'building'
| 'checkbox'
| 'dashboard'
| 'history'
| 'notes'
| 'target'
| 'user'
| 'versions';
@@ -0,0 +1,20 @@
import { type MessageDescriptor } from '@lingui/core';
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { type LayoutNavIconType } from './layout-nav-icon-type';
type NavColor = keyof typeof PRODUCT_STEPPER_SCENE.navTiles;
export type LayoutNavItemDefinition = {
children?: {
color: NavColor;
icon: LayoutNavIconType;
label: MessageDescriptor;
}[];
color: NavColor;
icon: LayoutNavIconType;
isActive: boolean;
isFolder?: boolean;
label: MessageDescriptor;
};
@@ -0,0 +1,208 @@
'use client';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react';
import { usePointerDragPositions } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { STEPPER_SHELL_CHROME } from '../components/ProductStepperShell';
import { WORKFLOW_GLYPHS } from './components/WorkflowIcons';
import { WORKFLOW_GRAPH } from './data/workflow-data';
import { useWorkflowAnimation } from './utils/use-workflow-animation';
const shell = PRODUCT_STEPPER_SCENE.shell;
const workflow = PRODUCT_STEPPER_SCENE.workflow;
const { Canvas, Shell, StageFit, SvgLayer } = STEPPER_SHELL_CHROME;
const NODE_WIDTH = 170;
const NODE_HEIGHT = 48;
const ARROW_GAP = 5;
const ARROW_MARKER_PATH =
'M1.7915 1.38672H8.18311C8.57541 1.38705 8.81458 1.81852 8.60693 2.15137L5.41064 7.26465C5.21481 7.57798 4.75882 7.57798 4.56299 7.26465L1.3667 2.15137C1.15906 1.81841 1.39896 1.38672 1.7915 1.38672Z';
const NodeCard = styled.div<{ $accent: string; $active: boolean }>`
align-items: center;
background: ${shell.cardBackground};
border: 1px solid
${({ $accent, $active }) => ($active ? $accent : shell.borderStrong)};
border-radius: 8px;
box-shadow: ${PRODUCT_STEPPER_SCENE.nodeShadow};
display: flex;
gap: 8px;
padding: 8px;
position: absolute;
width: ${NODE_WIDTH}px;
z-index: 2;
`;
const NodeIconBox = styled.div<{ $ink: string }>`
align-items: center;
background: ${shell.tint};
border-radius: 4px;
color: ${({ $ink }) => $ink};
display: flex;
flex-shrink: 0;
height: 30px;
justify-content: center;
width: 30px;
`;
const NodeRight = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
`;
const NodeLabel = styled.span`
color: ${shell.textTertiary};
font-size: 10px;
font-weight: 500;
`;
const NodeName = styled.div`
color: ${shell.text};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
function getEdgePath(
from: { x: number; y: number },
to: { x: number; y: number },
): string {
const sourceX = from.x + NODE_WIDTH / 2;
const sourceY = from.y + NODE_HEIGHT;
const targetX = to.x + NODE_WIDTH / 2;
const targetY = to.y - ARROW_GAP;
const midY = (sourceY + targetY) / 2;
return `M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`;
}
export function WorkflowVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
draggingId: dragging,
handlePointerDown,
positions,
} = usePointerDragPositions(() =>
Object.fromEntries(
WORKFLOW_GRAPH.nodes.map((node) => [node.id, { x: node.x, y: node.y }]),
),
);
const activeNodes = useWorkflowAnimation(active);
const { i18n } = useLingui();
const sourceNodeIds = [
...new Set(WORKFLOW_GRAPH.edges.map((edge) => edge.from)),
];
return (
<Shell active={active}>
<Canvas {...canvasHandlers}>
<StageFit baseScale={1.05} designHeight={560} designWidth={540}>
<SvgLayer>
<defs>
<marker
id="workflowArrow"
markerHeight={8}
markerWidth={10}
refX={5}
refY={4}
>
<path
d={ARROW_MARKER_PATH}
fill={shell.cardBackground}
stroke={shell.borderStrong}
/>
</marker>
</defs>
{WORKFLOW_GRAPH.edges.map((edge) => {
const from = positions[edge.from];
const to = positions[edge.to];
if (!from || !to) {
return null;
}
const isActive =
activeNodes.has(edge.from) && activeNodes.has(edge.to);
return (
<path
d={getEdgePath(from, to)}
fill="none"
key={`${edge.from}-${edge.to}`}
markerEnd="url(#workflowArrow)"
stroke={isActive ? shell.borderStrong : shell.borderMedium}
strokeWidth={1}
style={{ transition: 'stroke 0.3s' }}
/>
);
})}
</SvgLayer>
{WORKFLOW_GRAPH.nodes.map((node) => {
const position = positions[node.id];
const Icon = WORKFLOW_GLYPHS.nodes[node.icon];
const accent = workflow.accents[node.accent];
return (
<NodeCard
$accent={accent}
$active={activeNodes.has(node.id)}
key={node.id}
onPointerDown={(event) => handlePointerDown(node.id, event)}
style={{
cursor: dragging === node.id ? 'grabbing' : 'grab',
left: position.x,
top: position.y,
transition:
dragging === node.id ? 'none' : 'border-color 0.3s ease',
}}
>
<NodeIconBox $ink={accent}>
<Icon size={16} stroke={1.8} />
</NodeIconBox>
<NodeRight>
<NodeLabel>{i18n._(node.type)}</NodeLabel>
<NodeName>{i18n._(node.label)}</NodeName>
</NodeRight>
</NodeCard>
);
})}
<SvgLayer style={{ zIndex: 3 }}>
{sourceNodeIds.map((id) => {
const position = positions[id];
if (!position) {
return null;
}
return (
<circle
cx={position.x + NODE_WIDTH / 2}
cy={position.y + NODE_HEIGHT}
fill={shell.cardBackground}
key={`handle-${id}`}
r={3.5}
stroke={shell.borderStrong}
strokeWidth={1}
/>
);
})}
</SvgLayer>
</StageFit>
</Canvas>
</Shell>
);
}
@@ -0,0 +1,31 @@
import {
IconBrain,
IconCheck,
IconFilter,
IconPlaylistAdd,
IconPlus,
IconReload,
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { type WorkflowIconName } from '../types/workflow-icon-name';
const NODE_ICONS: Record<WorkflowIconName, typeof IconPlus> = {
brain: IconBrain,
filter: IconFilter,
playlistAdd: IconPlaylistAdd,
plus: IconPlus,
reload: IconReload,
search: IconSearch,
send: IconSend,
};
function WorkflowCheckGlyph({ color }: { color: string }) {
return <IconCheck color={color} size={8} stroke={2.5} />;
}
export const WORKFLOW_GLYPHS = {
Check: WorkflowCheckGlyph,
nodes: NODE_ICONS,
};
@@ -0,0 +1,99 @@
import { msg } from '@lingui/core/macro';
import { type WorkflowEdgeDefinition } from '../types/workflow-edge-definition';
import { type WorkflowNodeDefinition } from '../types/workflow-node-definition';
const TRUNK_X = 185;
const LEFT_X = 10;
const RIGHT_X = 360;
export const WORKFLOW_GRAPH: {
animationSequence: string[];
edges: WorkflowEdgeDefinition[];
nodes: WorkflowNodeDefinition[];
stepIntervalMs: number;
} = {
nodes: [
{
id: 'trigger',
type: msg`Trigger`,
label: msg`Record is Created`,
icon: 'playlistAdd',
accent: 'blue',
x: TRUNK_X,
y: 16,
},
{
id: 'filter',
type: msg`Action`,
label: msg`Filter`,
icon: 'filter',
accent: 'green',
x: TRUNK_X,
y: 136,
},
{
id: 'search',
type: msg`Action`,
label: msg`Search Records`,
icon: 'search',
accent: 'gray',
x: TRUNK_X,
y: 256,
},
{
id: 'ai',
type: msg`Action`,
label: msg`AI Agent`,
icon: 'brain',
accent: 'pink',
x: TRUNK_X,
y: 376,
},
{
id: 'update',
type: msg`Action`,
label: msg`Update Record`,
icon: 'reload',
accent: 'gray',
x: LEFT_X,
y: 496,
},
{
id: 'email',
type: msg`Action`,
label: msg`Send Email`,
icon: 'send',
accent: 'red',
x: TRUNK_X,
y: 496,
},
{
id: 'create',
type: msg`Action`,
label: msg`Create Record`,
icon: 'plus',
accent: 'gray',
x: RIGHT_X,
y: 496,
},
],
edges: [
{ from: 'trigger', to: 'filter' },
{ from: 'filter', to: 'search' },
{ from: 'search', to: 'ai' },
{ from: 'ai', to: 'update' },
{ from: 'ai', to: 'email' },
{ from: 'ai', to: 'create' },
],
animationSequence: [
'trigger',
'filter',
'search',
'ai',
'update',
'email',
'create',
],
stepIntervalMs: 800,
};
@@ -0,0 +1 @@
export { WorkflowVisual } from './WorkflowVisual';
@@ -0,0 +1,4 @@
export type WorkflowEdgeDefinition = {
from: string;
to: string;
};
@@ -0,0 +1,8 @@
export type WorkflowIconName =
| 'brain'
| 'filter'
| 'playlistAdd'
| 'plus'
| 'reload'
| 'search'
| 'send';
@@ -0,0 +1,13 @@
import { type MessageDescriptor } from '@lingui/core';
import { type WorkflowIconName } from './workflow-icon-name';
export type WorkflowNodeDefinition = {
accent: 'blue' | 'gray' | 'green' | 'pink' | 'red';
icon: WorkflowIconName;
id: string;
label: MessageDescriptor;
type: MessageDescriptor;
x: number;
y: number;
};
@@ -1,283 +0,0 @@
'use client';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { usePointerDragPositions } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import {
DATA_MODEL_GRAPH,
type EntityConnection,
} from '../data/data-model-data';
import { DATA_MODEL_ICONS } from './DataModelIcons';
import { DrawEdge } from './DrawEdge';
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
const shell = PRODUCT_STEPPER_SCENE.shell;
const badges = PRODUCT_STEPPER_SCENE.badges;
const entityTones = PRODUCT_STEPPER_SCENE.entityTones;
const { Canvas, Shell, SvgLayer } = STEPPER_SHELL_CHROME;
const EntityCard = styled.div`
backdrop-filter: blur(14px);
background: ${shell.cardBackground};
border: 1px solid ${shell.borderMedium};
border-radius: 8px;
box-shadow: ${PRODUCT_STEPPER_SCENE.cardShadow};
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;
&[data-hovered] {
border-color: ${shell.borderStrong};
}
&: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 ${shell.borderLight};
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
padding: 4px 0;
`;
const EntityIcon = styled.span<{ $tint: string; $line: string }>`
align-items: center;
background: ${({ $tint }) => $tint};
border: 1px solid ${({ $line }) => $line};
border-radius: 3px;
color: ${shell.text};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const EntityLabel = styled.span`
color: ${shell.text};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
`;
const EntityMeta = styled.span`
color: ${shell.textTertiary};
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<{
$ink: string;
$line: string;
$tint: string;
}>`
align-items: center;
background: ${({ $tint }) => $tint};
border: 1px solid ${({ $line }) => $line};
border-radius: 2px;
color: ${({ $ink }) => $ink};
display: flex;
font-size: 8px;
font-weight: 500;
height: 14px;
justify-content: center;
width: 14px;
`;
const MetaBadgeText = styled.span`
color: ${shell.textMuted};
font-size: 9px;
font-weight: 400;
`;
const FieldRow = styled.div`
align-items: center;
color: ${shell.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: ${shell.textMuted};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const ExpandHint = styled.div`
align-items: center;
color: ${shell.textTertiary};
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;
type EntityPositions = Record<string, { x: number; y: number }>;
function getCardCenter(
positions: EntityPositions,
entityId: string,
): { x: number; y: number } {
const position = positions[entityId];
if (!position) {
return { x: 0, y: 0 };
}
return {
x: position.x + CARD_WIDTH / 2,
y: position.y + CARD_HEIGHT_ESTIMATE / 2,
};
}
export function DataModelVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
draggingId: dragging,
handlePointerDown,
positions,
} = usePointerDragPositions(() =>
Object.fromEntries(
DATA_MODEL_GRAPH.entities.map((entity) => [
entity.id,
{ x: entity.x, y: entity.y },
]),
),
);
const [hoveredEntity, setHoveredEntity] = useState<string | null>(null);
const isConnectionHighlighted = (connection: EntityConnection) =>
hoveredEntity === connection.from || hoveredEntity === connection.to;
return (
<Shell active={active} title="Data model">
<Canvas {...canvasHandlers}>
<SvgLayer>
{DATA_MODEL_GRAPH.connections.map((connection) => (
<DrawEdge
circleRadius={1.5}
elbow="horizontal-first"
from={getCardCenter(positions, connection.from)}
highlighted={isConnectionHighlighted(connection)}
key={`${connection.from}-${connection.to}`}
to={getCardCenter(positions, connection.to)}
/>
))}
</SvgLayer>
{DATA_MODEL_GRAPH.entities.map((entity) => {
const position = positions[entity.id];
const HeaderIcon = DATA_MODEL_ICONS.headers[entity.headerIcon];
const tone = entityTones[entity.tone];
const badge = entity.isCustom ? badges.custom : badges.standard;
return (
<EntityCard
data-hovered={
hoveredEntity === entity.id || dragging === entity.id
? ''
: undefined
}
key={entity.id}
onPointerDown={(event) => handlePointerDown(entity.id, event)}
onPointerEnter={() => setHoveredEntity(entity.id)}
onPointerLeave={() => setHoveredEntity(null)}
style={{
left: position.x,
top: position.y,
transition:
dragging === entity.id ? 'none' : 'border-color 0.15s',
}}
>
<EntityHeader>
<EntityIcon $line={tone.border} $tint={tone.background}>
<HeaderIcon />
</EntityIcon>
<EntityLabel>{entity.label}</EntityLabel>
<EntityMeta>· {entity.meta}</EntityMeta>
<MetaBadge>
<MetaBadgeIcon
$ink={badge.text}
$line={badge.border}
$tint={badge.background}
>
L
</MetaBadgeIcon>
<MetaBadgeText>
{entity.isCustom ? 'Custom' : 'Standard'}
</MetaBadgeText>
</MetaBadge>
</EntityHeader>
<InnerCard>
{entity.fields.map((field) => {
const Icon = DATA_MODEL_ICONS.fields[field.icon];
return (
<FieldRow key={field.label}>
<FieldIcon>
<Icon />
</FieldIcon>
{field.label}
</FieldRow>
);
})}
<ExpandHint>
<DATA_MODEL_ICONS.ChevronExpand /> {entity.expandCount} fields
</ExpandHint>
</InnerCard>
</EntityCard>
);
})}
</Canvas>
</Shell>
);
}
@@ -1,466 +0,0 @@
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
const inks = PRODUCT_STEPPER_SCENE.layout;
export type LayoutFieldIconType =
| 'calendar'
| 'link'
| 'map'
| 'money'
| 'target'
| 'user'
| 'users';
export type LayoutNavIconType =
| 'ai'
| 'automation'
| 'building'
| 'checkbox'
| 'letterS'
| 'notes'
| 'play'
| 'stripeS'
| 'target'
| 'user'
| 'versions';
function FieldGlyph({ type }: { type: LayoutFieldIconType }) {
const ink = inks.fieldInk;
const size = 10;
switch (type) {
case 'link':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<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={ink}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'user':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="8" cy="5.5" r="2.5" stroke={ink} strokeWidth={1.2} />
<path
d="M3.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
stroke={ink}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
case 'money':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<path
d="M8 2v12M5 4.5h4.5a1.5 1.5 0 010 3H5.5a1.5 1.5 0 000 3H11"
stroke={ink}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'target':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="8" cy="8" r="6" stroke={ink} strokeWidth={1.2} />
<circle cx="8" cy="8" r="3" stroke={ink} strokeWidth={1.2} />
<circle cx="8" cy="8" fill={ink} r="1" />
</svg>
);
case 'users':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="6" cy="5" r="2" stroke={ink} strokeWidth={1.2} />
<path
d="M2 13c0-2 1.5-3.5 4-3.5s4 1.5 4 3.5"
stroke={ink}
strokeLinecap="round"
strokeWidth={1.2}
/>
<circle cx="11" cy="5.5" r="1.5" stroke={ink} strokeWidth={1.2} />
<path
d="M12 9.5c1.5.5 2.5 1.5 2.5 3"
stroke={ink}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
case 'map':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<path
d="M3 4l3.5-1.5 3 1.5L13 2.5v9.5l-3.5 1.5-3-1.5L3 13.5V4z"
stroke={ink}
strokeLinejoin="round"
strokeWidth={1.2}
/>
<path d="M6.5 2.5v9M9.5 4v9" stroke={ink} strokeWidth={1.2} />
</svg>
);
case 'calendar':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<rect
height="10"
rx="1.5"
stroke={ink}
strokeWidth={1.2}
width="10"
x="3"
y="3.5"
/>
<path d="M3 7h10M6 2v2M10 2v2" stroke={ink} strokeWidth={1.2} />
</svg>
);
}
}
function NavGlyph({ type }: { type: LayoutNavIconType }) {
const ink = inks.navInk;
const size = 7;
switch (type) {
case 'building':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<path
d="M5 14V3l6 2v9M5 6H3v8h12V7h-4"
stroke={ink}
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'user':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="8" cy="5" r="2.5" stroke={ink} strokeWidth={1.4} />
<path
d="M3.5 14c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4"
stroke={ink}
strokeLinecap="round"
strokeWidth={1.4}
/>
</svg>
);
case 'target':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="8" cy="8" r="5" stroke={ink} strokeWidth={1.4} />
<circle cx="8" cy="8" r="2" stroke={ink} strokeWidth={1.4} />
<path d="M8 3v2M13 8h-2" stroke={ink} strokeWidth={1.4} />
</svg>
);
case 'checkbox':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<rect
height="10"
rx="2"
stroke={ink}
strokeWidth={1.4}
width="10"
x="3"
y="3"
/>
<path
d="M6 8l1.5 1.5L10 6"
stroke={ink}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'notes':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<rect
height="10"
rx="1.5"
stroke={ink}
strokeWidth={1.4}
width="8"
x="4"
y="3"
/>
<path
d="M6.5 6h3M6.5 8.5h3"
stroke={ink}
strokeLinecap="round"
strokeWidth={1.4}
/>
</svg>
);
case 'automation':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<circle cx="8" cy="8" r="5" stroke={ink} strokeWidth={1.4} />
<path
d="M8 5v3l2 1"
stroke={ink}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'play':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<path
d="M5 3.5l8 4.5-8 4.5V3.5z"
stroke={ink}
strokeLinejoin="round"
strokeWidth={1.4}
/>
</svg>
);
case 'versions':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<rect
height="8"
rx="1"
stroke={ink}
strokeWidth={1.4}
width="6"
x="5"
y="4"
/>
<path d="M3 6v6a1 1 0 001 1h6" stroke={ink} strokeWidth={1.4} />
</svg>
);
case 'ai':
return (
<svg fill="none" height={size} viewBox="0 0 16 16" width={size}>
<path
d="M8 2l1.5 4L14 8l-4.5 2L8 14l-1.5-4L2 8l4.5-2L8 2z"
stroke={ink}
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
case 'letterS':
return (
<svg height={size} viewBox="0 0 16 16" width={size}>
<text
fill={inks.letterSInk}
fontFamily="Inter"
fontSize="9"
fontWeight="600"
textAnchor="middle"
x="8"
y="12"
>
S
</text>
</svg>
);
case 'stripeS':
return (
<svg height={size} viewBox="0 0 16 16" width={size}>
<text
fill={inks.stripeSInk}
fontFamily="Inter"
fontSize="9"
fontWeight="600"
textAnchor="middle"
x="8"
y="12"
>
S
</text>
</svg>
);
}
}
function EyeGlyph({ 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={inks.eyeInk}
strokeLinejoin="round"
strokeWidth={1.2}
/>
<circle cx="8" cy="8" r="1.5" stroke={inks.eyeInk} 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={inks.eyeHiddenInk}
strokeLinejoin="round"
strokeWidth={1.2}
/>
<path
d="M3 3l10 10"
stroke={inks.eyeHiddenInk}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
function ChevronLeftGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M10 4l-4 4 4 4"
stroke={inks.eyeInk}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
}
function DotsGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 4 12" width={5}>
<circle cx="2" cy="2" fill={inks.eyeInk} r="0.8" />
<circle cx="2" cy="6" fill={inks.eyeInk} r="0.8" />
<circle cx="2" cy="10" fill={inks.eyeInk} r="0.8" />
</svg>
);
}
function DotsWhiteGlyph() {
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>
);
}
function GripGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 8 12" width={7}>
<circle cx="3" cy="2" fill={inks.eyeInk} r="0.8" />
<circle cx="5" cy="2" fill={inks.eyeInk} r="0.8" />
<circle cx="3" cy="6" fill={inks.eyeInk} r="0.8" />
<circle cx="5" cy="6" fill={inks.eyeInk} r="0.8" />
<circle cx="3" cy="10" fill={inks.eyeInk} r="0.8" />
<circle cx="5" cy="10" fill={inks.eyeInk} r="0.8" />
</svg>
);
}
function PaintGlyph() {
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>
);
}
function CheckGlyph() {
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>
);
}
function ListGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M3 4h4M3 8h4M3 12h4M9 4h4M9 8h4M9 12h4"
stroke={inks.fieldInk}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
function SparkGlyph() {
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={inks.eyeInk}
strokeLinejoin="round"
strokeWidth={1.2}
/>
</svg>
);
}
function NewSectionGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M3 4h10M3 8h5M3 12h10M12 8v4M10 10h4"
stroke={inks.fieldInk}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
function PlusGlyph() {
return (
<svg fill="none" height={10} viewBox="0 0 16 16" width={10}>
<path
d="M8 3v10M3 8h10"
stroke={inks.fieldInk}
strokeLinecap="round"
strokeWidth={1.2}
/>
</svg>
);
}
export const LAYOUT_GLYPHS = {
Check: CheckGlyph,
ChevronLeft: ChevronLeftGlyph,
Dots: DotsGlyph,
DotsWhite: DotsWhiteGlyph,
Eye: EyeGlyph,
Field: FieldGlyph,
Grip: GripGlyph,
List: ListGlyph,
Nav: NavGlyph,
NewSection: NewSectionGlyph,
Paint: PaintGlyph,
Plus: PlusGlyph,
Spark: SparkGlyph,
};
@@ -1,347 +0,0 @@
'use client';
import {
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { LAYOUT_CHROME } from '../data/layout-chrome';
import { LAYOUT_EDITOR_CONTENT } from '../data/layout-data';
import { LAYOUT_GLYPHS } from './LayoutIcons';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
const {
Check: CheckGlyph,
ChevronLeft: ChevronLeftGlyph,
Dots: DotsGlyph,
DotsWhite: DotsWhiteGlyph,
Eye: EyeGlyph,
Field: FieldGlyph,
Grip: GripGlyph,
List: ListGlyph,
Nav: NavGlyph,
NewSection: NewSectionGlyph,
Paint: PaintGlyph,
Plus: PlusGlyph,
Spark: SparkGlyph,
} = LAYOUT_GLYPHS;
const {
ActionButton,
ActionsBar,
AddIconBox,
AddSectionRow,
AddText,
BlueHeader,
Canvas,
DoneButton,
EditableRow,
EditableText,
FieldDot,
FieldIconBox,
FieldLabels,
FieldName,
FieldRow,
FieldType,
HeaderCenter,
HeaderLeft,
HeaderSave,
HeaderTitle,
MainCard,
NavBreadcrumb,
NavChevron,
NavIconBox,
NavItem,
NavPanel,
NavSectionLabel,
NavSubItem,
NavSuffix,
NewFieldsColumn,
NewFieldsDescription,
NewFieldsRow,
NewFieldsTitle,
PanelActionButton,
PanelBackButton,
PanelFields,
PanelHeader,
PanelIconBox,
PanelSubBar,
PanelSubLabel,
PanelTitleBold,
PanelTitleGroup,
PanelTitleSub,
RightPanel,
SectionName,
SectionRow,
WidgetChip,
WidgetIcon,
WidgetInner,
WidgetLabel,
WidgetPanel,
WidgetRow,
WidgetSectionLabel,
WidgetTitle,
WidgetValue,
} = LAYOUT_CHROME;
const ROW_STEP_PX = 22;
export function LayoutVisual({ active }: { active: boolean }) {
const [fields, setFields] = useState(LAYOUT_EDITOR_CONTENT.fields);
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartY = useRef(0);
const toggleVisibility = (fieldId: string) => {
setFields((previous) =>
previous.map((field) =>
field.id === fieldId ? { ...field, visible: !field.visible } : field,
),
);
};
const handleDragStart = (
fieldId: string,
event: ReactPointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDraggingId(fieldId);
dragStartY.current = event.clientY;
};
const handleDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingId) {
return;
}
const deltaY = event.clientY - dragStartY.current;
const steps = Math.round(deltaY / ROW_STEP_PX);
if (steps === 0) {
return;
}
setFields((previous) => {
const index = previous.findIndex((field) => field.id === draggingId);
if (index === -1) {
return previous;
}
const target = Math.max(0, Math.min(previous.length - 1, index + steps));
if (target === index) {
return previous;
}
const next = [...previous];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
return next;
});
dragStartY.current = event.clientY;
};
const handleDragEnd = () => setDraggingId(null);
const handleLostCapture = () => {
if (draggingId) {
setDraggingId(null);
}
};
const sections = [...new Set(fields.map((field) => field.section))];
return (
<Canvas data-active={active ? '' : undefined}>
<MainCard>
<BlueHeader>
<HeaderLeft>
<DotsWhiteGlyph />
</HeaderLeft>
<HeaderCenter>
<PaintGlyph />
<HeaderTitle>Layout edition</HeaderTitle>
</HeaderCenter>
<HeaderSave>
<CheckGlyph /> Save
</HeaderSave>
</BlueHeader>
</MainCard>
<WidgetPanel>
<WidgetInner>
<WidgetTitle>Widget name</WidgetTitle>
<WidgetSectionLabel>General</WidgetSectionLabel>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="link" />
</WidgetIcon>
<WidgetLabel>URL</WidgetLabel>
<WidgetChip>qonto.com</WidgetChip>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="user" />
</WidgetIcon>
<WidgetLabel>Account O...</WidgetLabel>
<WidgetValue>Phil Schiller</WidgetValue>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="map" />
</WidgetIcon>
<WidgetLabel>Address</WidgetLabel>
<WidgetValue>18 Rue De Navarin, 750...</WidgetValue>
</WidgetRow>
<WidgetRow>
<WidgetIcon>
<FieldGlyph type="target" />
</WidgetIcon>
<WidgetLabel>ICP</WidgetLabel>
<WidgetValue> True</WidgetValue>
</WidgetRow>
</WidgetInner>
</WidgetPanel>
<NavPanel>
<NavSectionLabel>Workspace</NavSectionLabel>
{LAYOUT_EDITOR_CONTENT.navItems.map((item) => (
<div key={item.label}>
<NavItem data-active={item.isActive ? '' : undefined}>
<NavIconBox
$tint={PRODUCT_STEPPER_SCENE.navTints[item.background]}
>
<NavGlyph type={item.icon} />
</NavIconBox>
{item.label}
{item.suffix ? <NavSuffix>· {item.suffix}</NavSuffix> : null}
{item.isFolder ? <NavChevron></NavChevron> : null}
</NavItem>
{item.children?.map((child) => (
<NavSubItem key={child.label}>
<NavBreadcrumb />
<NavIconBox
$tint={PRODUCT_STEPPER_SCENE.navTints[child.background]}
>
<NavGlyph type={child.icon} />
</NavIconBox>
{child.label}
</NavSubItem>
))}
</div>
))}
</NavPanel>
<ActionsBar>
<ActionButton>+ New record</ActionButton>
<ActionButton> Enrich</ActionButton>
<ActionButton> Edit actions</ActionButton>
</ActionsBar>
<RightPanel
onLostPointerCapture={handleLostCapture}
onPointerCancel={handleDragEnd}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
>
<PanelHeader>
<PanelBackButton>
<ChevronLeftGlyph />
</PanelBackButton>
<PanelIconBox>
<ListGlyph />
</PanelIconBox>
<PanelTitleGroup>
<PanelTitleBold>Fields</PanelTitleBold>
<PanelTitleSub>Fields widget</PanelTitleSub>
</PanelTitleGroup>
<PanelActionButton>
<SparkGlyph />
</PanelActionButton>
</PanelHeader>
<PanelSubBar>
<PanelBackButton>
<ChevronLeftGlyph />
</PanelBackButton>
<PanelSubLabel>Layout</PanelSubLabel>
</PanelSubBar>
<PanelFields>
{sections.map((section, sectionNumber) => (
<div key={section}>
<SectionRow>
<GripGlyph />
<SectionName>{section}</SectionName>
<PanelActionButton>
<DotsGlyph />
</PanelActionButton>
</SectionRow>
{sectionNumber === 0 ? (
<EditableRow>
<EditableText>Industry</EditableText>
<DoneButton>Done</DoneButton>
</EditableRow>
) : null}
{fields
.filter((field) => field.section === section)
.map((field) => (
<FieldRow
data-dragging={draggingId === field.id ? '' : undefined}
key={field.id}
onPointerDown={(event) => handleDragStart(field.id, event)}
>
<FieldIconBox>
<FieldGlyph type={field.icon} />
</FieldIconBox>
<FieldLabels>
<FieldName>{field.label}</FieldName>
<FieldDot>·</FieldDot>
<FieldType>{field.type}</FieldType>
</FieldLabels>
<PanelActionButton
onClick={(event) => {
event.stopPropagation();
toggleVisibility(field.id);
}}
>
<EyeGlyph visible={field.visible} />
</PanelActionButton>
<PanelActionButton>
<DotsGlyph />
</PanelActionButton>
</FieldRow>
))}
{sectionNumber > 0 && sectionNumber < sections.length - 1 ? (
<AddSectionRow>
<AddIconBox>
<NewSectionGlyph />
</AddIconBox>
<AddText>Add a Section</AddText>
</AddSectionRow>
) : null}
</div>
))}
<NewFieldsRow>
<AddIconBox>
<PlusGlyph />
</AddIconBox>
<NewFieldsColumn>
<NewFieldsTitle>New fields</NewFieldsTitle>
<NewFieldsDescription>
Default position/visibility for field
</NewFieldsDescription>
</NewFieldsColumn>
</NewFieldsRow>
<AddSectionRow>
<AddIconBox>
<NewSectionGlyph />
</AddIconBox>
<AddText>Add a Section</AddText>
</AddSectionRow>
</PanelFields>
</RightPanel>
</Canvas>
);
}
@@ -16,10 +16,8 @@ import {
StepperSwipeDeck,
} from '@/ui';
import {
PRODUCT_STEPPER_STEPS,
type ProductStepperStep,
} from '../data/product-stepper-data';
import { PRODUCT_STEPPER_STEPS } from '../data/product-stepper-data';
import { type ProductStepperStep } from '../types/product-stepper-step';
const ContentRoot = styled.div`
display: grid;
@@ -1,101 +1,49 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
'use client';
import { styled } from '@linaria/react';
import { useRef, type ReactNode } from 'react';
import { useScaleToFit } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
const shell = PRODUCT_STEPPER_SCENE.shell;
const Wrapper = styled.div`
background: ${shell.background};
border-radius: 2px;
box-shadow: ${shell.shadowSm};
display: flex;
flex-direction: column;
font-family: ${shell.font};
height: 92%;
margin-left: auto;
margin-top: auto;
height: 100%;
opacity: 0.7;
overflow: hidden;
transition: opacity 0.3s;
width: 88%;
width: 100%;
&[data-active] {
opacity: 1;
}
`;
const Header = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 30px;
padding: 0 10px;
`;
const HeaderLogo = styled.span`
align-items: center;
background: ${shell.headerBackground};
border: 1px solid ${shell.headerBorder};
border-radius: 3px;
color: ${shell.text};
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const HeaderTitle = styled.span`
color: ${shell.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 HeaderButton = styled.span`
align-items: center;
border: 1px solid ${shell.borderMedium};
border-radius: 4px;
color: ${shell.textMuted};
display: flex;
height: 22px;
justify-content: center;
width: 22px;
`;
const HeaderCommandButton = styled.span`
align-items: center;
border: 1px solid ${shell.borderSubtle};
border-radius: 4px;
color: ${shell.textTertiary};
display: flex;
font-size: 12px;
font-weight: 500;
gap: 4px;
height: 22px;
padding: 0 6px;
`;
const Canvas = styled.div`
background: white;
border: 1px solid ${shell.borderMedium};
border-radius: 8px;
flex: 1;
margin: 0 10px 10px;
min-height: 0;
overflow: hidden;
position: relative;
user-select: none;
`;
const StageFitContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
`;
const StageBox = styled.div`
flex-shrink: 0;
position: relative;
`;
const SvgLayer = styled.svg`
height: 100%;
left: 0;
@@ -105,92 +53,45 @@ const SvgLayer = styled.svg`
width: 100%;
`;
function Shell({
active,
function Shell({ active, children }: { active: boolean; children: ReactNode }) {
return <Wrapper data-active={active ? '' : undefined}>{children}</Wrapper>;
}
function StageFit({
baseScale = 1,
children,
title,
designHeight,
designWidth,
zoom = 1,
}: {
active: boolean;
baseScale?: number;
children: ReactNode;
title: string;
designHeight: number;
designWidth: number;
zoom?: number;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const fit = useScaleToFit(containerRef, designWidth, designHeight, 1);
const scale = Math.min(baseScale, zoom * fit || baseScale);
return (
<Wrapper data-active={active ? '' : undefined}>
<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>
<HeaderButton>
<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>
</HeaderButton>
<HeaderButton>
<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>
</HeaderButton>
<HeaderCommandButton>
<svg
fill="none"
height="12"
stroke={shell.textMuted}
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
</HeaderCommandButton>
</HeaderActions>
</Header>
{children}
</Wrapper>
<StageFitContainer ref={containerRef}>
<StageBox
style={{
height: designHeight,
transform: `scale(${scale})`,
width: designWidth,
}}
>
{children}
</StageBox>
</StageFitContainer>
);
}
export const STEPPER_SHELL_CHROME = {
Canvas,
Shell,
StageFit,
SvgLayer,
};
@@ -3,16 +3,14 @@
import { styled } from '@linaria/react';
import { type ComponentType } from 'react';
import { mediaUp } from '@/tokens';
import { EASING, mediaUp } from '@/tokens';
import { DataModelVisual } from './DataModelVisual';
import { LayoutVisual } from './LayoutVisual';
import {
PRODUCT_STEPPER_STEPS,
type ProductStepperVisualKey,
} from '../data/product-stepper-data';
import { DataModelVisual } from '../DataModelVisual';
import { LayoutVisual } from '../LayoutVisual';
import { WorkflowVisual } from '../WorkflowVisual';
import { PRODUCT_STEPPER_STEPS } from '../data/product-stepper-data';
import { type ProductStepperVisualKey } from '../types/product-stepper-visual-key';
import { ProductStepperVisualFrame } from './ProductStepperVisualFrame';
import { WorkflowVisual } from './WorkflowVisual';
const VISUALS: Record<
ProductStepperVisualKey,
@@ -54,11 +52,15 @@ const Slide = styled.div`
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 0.4s ease;
transform: translateY(18px) scale(0.985);
transition:
opacity 0.55s ${EASING.gentle},
transform 0.55s ${EASING.standard};
&[data-active] {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
`;
@@ -1,149 +0,0 @@
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
export type WorkflowIconName =
| 'playlistAdd'
| 'plus'
| 'reload'
| 'repeat'
| 'search'
| 'send';
function IconPlaylistAdd() {
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>
);
}
function IconSearch() {
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>
);
}
function IconRepeat() {
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>
);
}
function IconSend() {
return (
<svg
fill="none"
height={16}
stroke={PRODUCT_STEPPER_SCENE.workflow.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>
);
}
function IconReload() {
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>
);
}
function IconPlus() {
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>
);
}
function WorkflowCheckGlyph({ 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>
);
}
export const WORKFLOW_GLYPHS = {
Check: WorkflowCheckGlyph,
nodes: {
playlistAdd: IconPlaylistAdd,
plus: IconPlus,
reload: IconReload,
repeat: IconRepeat,
search: IconSearch,
send: IconSend,
},
};
@@ -1,273 +0,0 @@
'use client';
import { styled } from '@linaria/react';
import { usePointerDragPositions } from '@/platform/motion';
import { PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import { DrawEdge } from './DrawEdge';
import { STEPPER_SHELL_CHROME } from './ProductStepperShell';
import { useWorkflowAnimation } from '../utils/use-workflow-animation';
import { WORKFLOW_GRAPH } from '../data/workflow-data';
import { WORKFLOW_GLYPHS } from './WorkflowIcons';
const shell = PRODUCT_STEPPER_SCENE.shell;
const workflow = PRODUCT_STEPPER_SCENE.workflow;
const { Canvas, Shell, SvgLayer } = STEPPER_SHELL_CHROME;
const NODE_WIDTH = WORKFLOW_GRAPH.nodeWidthPx;
const NODE_HEIGHT = WORKFLOW_GRAPH.nodeHeightPx;
const LABEL_TONES = {
amber: workflow.amber,
gray: workflow.gray,
green: workflow.green,
};
const NodeCard = styled.div`
align-items: center;
background: ${shell.cardBackground};
border: 1px solid ${shell.borderStrong};
border-radius: 8px;
box-shadow: ${PRODUCT_STEPPER_SCENE.nodeShadow};
display: flex;
gap: 8px;
min-width: ${NODE_WIDTH}px;
padding: 8px;
position: absolute;
z-index: 2;
`;
const NodeIconBox = styled.div`
align-items: center;
background: ${shell.tint};
border-radius: 4px;
color: ${shell.textMuted};
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<{ $ink: string }>`
color: ${({ $ink }) => $ink};
font-size: 10px;
font-weight: 600;
`;
const NodeCheck = styled.span<{ $tint: string }>`
align-items: center;
background: ${({ $tint }) => $tint};
border-radius: 2px;
display: flex;
height: 12px;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
width: 12px;
&[data-visible] {
opacity: 1;
}
`;
const NodeName = styled.div`
color: ${shell.text};
font-size: 12px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&[data-dimmed] {
color: ${shell.textTertiary};
}
`;
const IterationLabel = styled.span`
background: ${shell.cardBackground};
border: 1px solid ${shell.borderStrong};
border-radius: 4px;
color: ${shell.textTertiary};
font-size: 9px;
font-weight: 600;
padding: 2px 4px;
position: absolute;
white-space: nowrap;
z-index: 3;
`;
type NodePositions = Record<string, { x: number; y: number }>;
function getNodeCenter(position: { x: number; y: number }): {
x: number;
y: number;
} {
return { x: position.x + NODE_WIDTH / 2, y: position.y + NODE_HEIGHT / 2 };
}
function IteratorLoopPath({ positions }: { positions: NodePositions }) {
const iteratorPosition = positions.iterator;
const emailPosition = positions.email;
if (!iteratorPosition || !emailPosition) {
return null;
}
const startX = iteratorPosition.x + NODE_WIDTH;
const startY = iteratorPosition.y + NODE_HEIGHT / 2;
const horizontalEnd = startX + 117;
const cornerRadius = 8;
const verticalEnd = emailPosition.y;
const emailCenterX = emailPosition.x + NODE_WIDTH / 2;
const midY = verticalEnd - 28;
const loopColor = shell.borderStrong;
const arrowHalf = 3;
return (
<g>
<circle cx={startX} cy={startY} fill={loopColor} r={2.5} />
<path
d={[
`M${startX},${startY}`,
`H${horizontalEnd - cornerRadius}`,
`Q${horizontalEnd},${startY} ${horizontalEnd},${startY + cornerRadius}`,
`V${midY - cornerRadius}`,
`Q${horizontalEnd},${midY} ${horizontalEnd - cornerRadius},${midY}`,
`H${emailCenterX + cornerRadius}`,
`Q${emailCenterX},${midY} ${emailCenterX},${midY + cornerRadius}`,
`V${verticalEnd}`,
].join(' ')}
fill="none"
stroke={loopColor}
strokeWidth={0.75}
/>
<path
d={`M${emailCenterX - arrowHalf},${verticalEnd - arrowHalf} L${emailCenterX},${verticalEnd} L${emailCenterX + arrowHalf},${verticalEnd - arrowHalf}`}
fill="none"
stroke={loopColor}
strokeWidth={0.75}
/>
<circle cx={emailCenterX} cy={verticalEnd} fill={loopColor} r={2.5} />
</g>
);
}
export function WorkflowVisual({ active }: { active: boolean }) {
const {
canvasHandlers,
draggingId: dragging,
handlePointerDown,
positions,
} = usePointerDragPositions(() =>
Object.fromEntries(
WORKFLOW_GRAPH.nodes.map((node) => [node.id, { x: node.x, y: node.y }]),
),
);
const activeNodes = useWorkflowAnimation(active);
return (
<Shell active={active} title="Workflow Runs">
<Canvas {...canvasHandlers}>
<SvgLayer>
{WORKFLOW_GRAPH.edges.map((edge) => {
const fromPosition = positions[edge.from];
const toPosition = positions[edge.to];
if (!fromPosition || !toPosition) {
return null;
}
return (
<DrawEdge
circleRadius={2.5}
from={getNodeCenter(fromPosition)}
highlighted={
activeNodes.has(edge.from) && activeNodes.has(edge.to)
}
key={`${edge.from}-${edge.to}`}
to={getNodeCenter(toPosition)}
/>
);
})}
<IteratorLoopPath positions={positions} />
</SvgLayer>
{WORKFLOW_GRAPH.nodes.map((node) => {
const position = positions[node.id];
const Icon = WORKFLOW_GLYPHS.nodes[node.icon];
const checkTint = node.dimmed
? workflow.grayBackground
: workflow.tealBackground;
const checkInk = node.dimmed ? workflow.gray : workflow.green;
return (
<NodeCard
key={node.id}
onPointerDown={(event) => handlePointerDown(node.id, event)}
style={{
cursor: dragging === node.id ? 'grabbing' : 'grab',
left: position.x,
top: position.y,
transition: dragging === node.id ? 'none' : 'box-shadow 0.15s',
}}
>
<NodeIconBox>
<Icon />
</NodeIconBox>
<NodeRight>
<NodeLabelRow>
<NodeLabel $ink={LABEL_TONES[node.labelTone]}>
{node.type}
</NodeLabel>
{node.badge ? (
<>
<NodeLabel $ink={LABEL_TONES[node.labelTone]}>
{node.badge}
</NodeLabel>
<NodeCheck
$tint={checkTint}
data-visible={activeNodes.has(node.id) ? '' : undefined}
>
<WORKFLOW_GLYPHS.Check color={checkInk} />
</NodeCheck>
</>
) : null}
</NodeLabelRow>
<NodeName data-dimmed={node.dimmed ? '' : undefined}>
{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>
</Canvas>
</Shell>
);
}
@@ -1,118 +0,0 @@
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import {
type DataModelFieldIcon,
type DataModelHeaderIcon,
} from '../components/DataModelIcons';
export type EntityTone = keyof typeof PRODUCT_STEPPER_SCENE.entityTones;
export type EntityDefinition = {
expandCount: number;
fields: { icon: DataModelFieldIcon; label: string }[];
headerIcon: DataModelHeaderIcon;
id: string;
isCustom: boolean;
label: string;
meta: string;
tone: EntityTone;
x: number;
y: number;
};
export type EntityConnection = {
from: string;
to: string;
};
export const DATA_MODEL_GRAPH: {
connections: EntityConnection[];
entities: EntityDefinition[];
} = {
entities: [
{
id: 'workspaces',
label: 'Workspaces',
meta: '22',
isCustom: true,
headerIcon: 'userScreenSmall',
tone: 'green',
fields: [
{ icon: 'building', label: 'Company' },
{ icon: 'user', label: 'Users' },
],
expandCount: 21,
x: 40,
y: 40,
},
{
id: 'companies',
label: 'Companies',
meta: '39',
isCustom: false,
headerIcon: 'buildingSmall',
tone: 'indigo',
fields: [
{ icon: 'apps', label: 'Workspace' },
{ icon: 'tag', label: '31 fields' },
],
expandCount: 8,
x: 290,
y: 20,
},
{
id: 'users',
label: 'Users',
meta: '497',
isCustom: true,
headerIcon: 'usersSmall',
tone: 'purple',
fields: [
{ icon: 'user', label: 'People' },
{ icon: 'apps', label: 'Workspace' },
],
expandCount: 32,
x: 40,
y: 310,
},
{
id: 'people',
label: 'People',
meta: '648',
isCustom: false,
headerIcon: 'userSmall',
tone: 'indigo',
fields: [
{ icon: 'building', label: 'Company' },
{ icon: 'user', label: 'Users' },
{ icon: 'target', label: 'Opportunity' },
],
expandCount: 4,
x: 280,
y: 400,
},
{
id: 'opportunities',
label: 'Opportunities',
meta: '42',
isCustom: false,
headerIcon: 'targetSmall',
tone: 'red',
fields: [
{ icon: 'building', label: 'Company' },
{ icon: 'tag', label: '12 fields' },
],
expandCount: 23,
x: 380,
y: 190,
},
],
connections: [
{ from: 'workspaces', to: 'companies' },
{ from: 'workspaces', to: 'users' },
{ from: 'users', to: 'people' },
{ from: 'companies', to: 'people' },
{ from: 'companies', to: 'opportunities' },
{ from: 'people', to: 'opportunities' },
],
};
@@ -1,144 +0,0 @@
import { type PRODUCT_STEPPER_SCENE } from '@/tokens/feature-scenes/product-stepper-scene';
import {
type LayoutFieldIconType,
type LayoutNavIconType,
} from '../components/LayoutIcons';
type NavTint = keyof typeof PRODUCT_STEPPER_SCENE.navTints;
export type LayoutFieldDefinition = {
icon: LayoutFieldIconType;
id: string;
label: string;
section: string;
type: string;
visible: boolean;
};
export type LayoutNavItemDefinition = {
background: NavTint;
children?: { background: NavTint; icon: LayoutNavIconType; label: string }[];
icon: LayoutNavIconType;
isActive: boolean;
isFolder?: boolean;
label: string;
suffix?: string;
};
export const LAYOUT_EDITOR_CONTENT: {
fields: LayoutFieldDefinition[];
navItems: LayoutNavItemDefinition[];
} = {
fields: [
{
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,
},
],
navItems: [
{
icon: 'building',
label: 'Companies',
isActive: true,
background: 'indigo',
},
{ icon: 'user', label: 'People', isActive: false, background: 'indigo' },
{
icon: 'target',
label: 'Opportunities',
isActive: false,
background: 'red',
},
{
icon: 'checkbox',
label: 'Tasks',
isActive: false,
background: 'teal',
},
{ icon: 'notes', label: 'Notes', isActive: false, background: 'teal' },
{
icon: 'letterS',
label: 'Sales Dashboard',
isActive: false,
background: 'yellow',
suffix: 'Dashboard',
},
{
icon: 'automation',
label: 'Workflows',
isActive: false,
background: 'peach',
isFolder: true,
children: [
{ icon: 'automation', label: 'Workflows', background: 'gray' },
{ icon: 'play', label: 'Workflows runs', background: 'gray' },
{
icon: 'versions',
label: 'Workflows versions',
background: 'gray',
},
],
},
{ icon: 'ai', label: 'Claude', isActive: false, background: 'gray' },
{
icon: 'stripeS',
label: 'Stripe',
isActive: false,
background: 'gray',
isFolder: true,
},
],
};
@@ -1,16 +1,6 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type INFORMATIVE_MARKS } from '@/icons';
export type ProductStepperVisualKey = 'dataModel' | 'layout' | 'workflow';
export type ProductStepperStep = {
body: MessageDescriptor;
heading: MessageDescriptor;
icon: keyof typeof INFORMATIVE_MARKS;
visual: ProductStepperVisualKey;
};
import { type ProductStepperStep } from '../types/product-stepper-step';
export const PRODUCT_STEPPER_STEPS: readonly ProductStepperStep[] = [
{
@@ -1,110 +0,0 @@
import { type WorkflowIconName } from '../components/WorkflowIcons';
export type WorkflowNodeDefinition = {
badge?: string;
dimmed?: boolean;
icon: WorkflowIconName;
id: string;
label: string;
labelTone: 'amber' | 'gray' | 'green';
type: string;
x: number;
y: number;
};
export type WorkflowEdgeDefinition = {
from: string;
to: string;
};
const TRUNK_X = 55;
const RIGHT_X = 200;
const LEFT_X = 5;
export const WORKFLOW_GRAPH: {
animationSequence: string[];
edges: WorkflowEdgeDefinition[];
nodeHeightPx: number;
nodeWidthPx: number;
nodes: WorkflowNodeDefinition[];
stepIntervalMs: number;
} = {
nodes: [
{
id: 'trigger',
type: 'Trigger',
label: 'Record is Created',
icon: 'playlistAdd',
labelTone: 'green',
x: TRUNK_X,
y: 16,
badge: '1',
},
{
id: 'search',
type: 'Action',
label: 'Search Records',
icon: 'search',
labelTone: 'green',
x: TRUNK_X,
y: 92,
badge: '1',
},
{
id: 'iterator',
type: 'Flow',
label: 'Iterator',
icon: 'repeat',
labelTone: 'amber',
x: TRUNK_X,
y: 168,
},
{
id: 'email',
type: 'Action',
label: 'Send Email',
icon: 'send',
labelTone: 'amber',
x: RIGHT_X,
y: 280,
},
{
id: 'update',
type: 'Action',
label: 'Update Record',
icon: 'reload',
labelTone: 'gray',
x: LEFT_X,
y: 340,
badge: '3',
dimmed: true,
},
{
id: 'create',
type: 'Action',
label: 'Create Record',
icon: 'plus',
labelTone: 'green',
x: RIGHT_X - 5,
y: 400,
badge: '1',
},
],
edges: [
{ from: 'trigger', to: 'search' },
{ from: 'search', to: 'iterator' },
{ from: 'iterator', to: 'update' },
{ from: 'email', to: 'create' },
],
animationSequence: [
'trigger',
'search',
'iterator',
'email',
'update',
'create',
],
stepIntervalMs: 800,
nodeWidthPx: 170,
nodeHeightPx: 48,
};
@@ -0,0 +1,12 @@
import { type MessageDescriptor } from '@lingui/core';
import { type INFORMATIVE_MARKS } from '@/icons';
import { type ProductStepperVisualKey } from './product-stepper-visual-key';
export type ProductStepperStep = {
body: MessageDescriptor;
heading: MessageDescriptor;
icon: keyof typeof INFORMATIVE_MARKS;
visual: ProductStepperVisualKey;
};
@@ -0,0 +1 @@
export type ProductStepperVisualKey = 'dataModel' | 'layout' | 'workflow';
@@ -37,32 +37,32 @@ export const PRODUCT_STEPPER_SCENE = {
},
workflow: {
green: '#30a46c',
amber: '#946800',
gray: FICTION_PALETTE.inkFaint,
tealBackground: '#e7f9f5',
grayBackground: '#f9f9f9',
accents: {
blue: '#3e63dd',
gray: FICTION_PALETTE.inkMuted,
green: '#193b2d',
pink: '#d6409f',
red: '#e5484d',
},
},
navTints: {
gray: FICTION_PALETTE.borderMedium,
indigo: '#d9e2fc',
peach: '#ffdcc3',
red: '#fdd8d8',
teal: '#c7ebe5',
yellow: FICTION_PALETTE.yellowWash,
navTiles: {
blue: { background: '#d2deff', border: '#c1d0ff', icon: '#3a5bc7' },
red: { background: '#ffcdce', border: '#fdbdbe', icon: '#ce2c31' },
turquoise: { background: '#b8eae0', border: '#a1ded2', icon: '#008573' },
gray: { background: '#ebebeb', border: '#d6d6d6', icon: '#666666' },
orange: { background: '#ffd19a', border: '#ffc182', icon: '#cc4e00' },
},
layout: {
accent: FICTION_PALETTE.accent,
panelAccent: '#4a38f5',
glass: 'rgba(255, 255, 255, 0.9)',
dragWash: 'rgba(59, 130, 246, 0.04)',
chipWash: 'rgba(0, 0, 0, 0.02)',
saveBorder: 'rgba(255, 255, 255, 0.7)',
fieldInk: FICTION_PALETTE.inkMuted,
navInk: '#555',
eyeInk: FICTION_PALETTE.inkFaint,
eyeHiddenInk: FICTION_PALETTE.inkDisabled,
letterSInk: '#35290f',
stripeSInk: '#333',
},
cardShadow: '0 0 2px rgba(0, 0, 0, 0.08), 0 2px 2px rgba(0, 0, 0, 0.04)',
nodeShadow: '0 0 2px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)',